diff --git a/README.md b/README.md index e35dd35..0aae5fd 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ 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 | **NOT IMPLEMENTED / not authorized**; eligible only as future work after formal Stage-2 closure | +| Dual-Lab AI Query | **DL-02, DL-03, and DL-04 accepted**; DL-04 closure PASS / fresh governed review PASS / Owner acceptance completed ACCEPT / PASS; 0 material / 1 open non-blocking P3 finding; DL-05 NOT STARTED; not integrated, merged, or released; complete MVP unfinished; 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 | @@ -505,6 +505,29 @@ release lanes; they are not performed by documentation-only changes. | WF-01-03C through WF-01-03F | Deferred future work / post-release | | New project-status infrastructure | Not required; this section is the repository-facing release summary | +The exact accepted DL-02 implementation commit is +`bb39e8295a2fa5d69980396dbaf8c374c79a57b3`. Its fresh independent review passed; +Owner acceptance completed (`DL_02_ACCEPTED = YES`). The exact accepted DL-03 +implementation is `e4c529507c6f5a26a289af4f6d3bfc9e0b170c18`, with governed +review PASS and completed Owner acceptance ACCEPTED (`DL_03_ACCEPTED = YES`). +That review used fresh inspection and validation in the same conversation, +without a separate-reviewer identity claim. The accepted DL-04 closure candidate +`0972910c4d5e2bb7d70acbb50c4721234f20d0dc` has closure PASS and fresh governed +review PASS, with zero material findings and one OPEN_NON_BLOCKING P3 +argv-recording finding. Owner acceptance completed ACCEPT / PASS +(`DL_04_ACCEPTED = YES`). The accepted manifest SHA256 is +`f7dd14e1ea471032cf68e108d2ea85b144096d28f3aa6c339efd89bf27567449`. +That DL-04 review was fresh technical/governed inspection in the same conversation, +not a separate-human, separate-agent-identity, or separate-conversation attestation. +DL-05 has not started. Safe CI is not required at DL-04 and remains a DL-05 gate; +Safe CI has not run for DL-04 or DL-05. The complete Dual-Lab MVP remains +unfinished and is not yet accepted. See the +[canonical accepted DL-02, DL-03, and DL-04 closure record](docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md#dl-04-reviewed-closure-candidate) +for scope, exact parent/manifest hashes, retained validation evidence, and the open +finding. These local states grant +no standing live permission or live readiness and imply no production +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 retained separately and is intentionally excluded from the public release. @@ -526,9 +549,12 @@ 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. -- Begin any Dual-Lab AI Query work only after Stage-2 formal closure and a - separate authorized task. The completed Lab1 and Lab2 proofs grant no future - live authority. +- The next Owner decision is `AUTHORIZE_DL_05_READINESS_AND_P3_IMPACT_REVIEW`. + DL-04 Owner acceptance is complete; integration, DL-05, and its Safe CI remain + separately gated future work. Future DL-05 planning must decide whether the + open P3 argv-recording finding requires remediation before reusing the affected + external wrapper for exact argv / command-attestation evidence. DL-05 remains + unstarted. The completed Lab1 and Lab2 proofs grant no future live authority. - Keep the post-merge Safe CI maintenance findings OPEN: npm has 5 vulnerabilities (2 moderate, 2 high, 1 critical), and Next.js emitted 7 filesystem-tracing warnings. These are non-Stage-2-blocking maintenance items; diff --git a/day147_ai_assistance_deferred_risk_register.py b/day147_ai_assistance_deferred_risk_register.py index 21589e8..5356639 100644 --- a/day147_ai_assistance_deferred_risk_register.py +++ b/day147_ai_assistance_deferred_risk_register.py @@ -539,7 +539,11 @@ def write_day147_ai_assistance_deferred_risk_register_reports( json_path = Path(project_root) / REPORT_JSON html_path = Path(project_root) / REPORT_HTML json_path.parent.mkdir(parents=True, exist_ok=True) - json_path.write_text(json.dumps(safe_report, indent=2), encoding="utf-8") + json_path.write_text( + json.dumps(safe_report, indent=2), + encoding="utf-8", + newline="\n", + ) write_day147_ai_assistance_deferred_risk_register_html(safe_report, html_path) return json_path, html_path @@ -594,6 +598,7 @@ def write_day147_ai_assistance_deferred_risk_register_html( """, encoding="utf-8", + newline="\n", ) diff --git a/docs/automation_readiness/actual_automation_integration_plan.md b/docs/automation_readiness/actual_automation_integration_plan.md index da18f4a..5da3280 100644 --- a/docs/automation_readiness/actual_automation_integration_plan.md +++ b/docs/automation_readiness/actual_automation_integration_plan.md @@ -9,6 +9,29 @@ separate independent closure review and remote Safe CI. The default public review path remains Stage-0 offline evidence browsing. Stage 3 is **NOT STARTED / requires separate Owner authorization**. +**Dual-Lab local implementation status:** DL-02 implementation +`bb39e8295a2fa5d69980396dbaf8c374c79a57b3` passed fresh independent review +and Owner acceptance is **ACCEPTED**. DL-02 is accepted, but not integrated or merged. +DL-03 implementation `e4c529507c6f5a26a289af4f6d3bfc9e0b170c18` is +**ACCEPTED**, with governed review **PASS** and Owner acceptance **ACCEPTED**. +DL-03 is not integrated or merged. The review used fresh inspection and validation +in the same conversation, without a separate-reviewer identity claim. +DL-04 is **ACCEPTED**, with closure execution **PASS**, fresh governed review +**PASS**, and completed Owner acceptance **ACCEPTED / PASS**. Its exact accepted +closure candidate is `0972910c4d5e2bb7d70acbb50c4721234f20d0dc`; its accepted +manifest SHA256 is +`f7dd14e1ea471032cf68e108d2ea85b144096d28f3aa6c339efd89bf27567449`. +There are zero material findings and one **OPEN_NON_BLOCKING** P3 argv-recording +finding. Its review was fresh technical inspection in the same conversation, +not a separate-human, separate-agent-identity, or separate-conversation attestation. +DL-05 has not started; Safe CI is not required at DL-04 and remains a DL-05 gate. +Safe CI has not run for DL-04 or DL-05. +No standing or new live authority exists. The complete Dual-Lab MVP remains +unfinished, with no live readiness granted. See the +[canonical accepted DL-02, DL-03, and DL-04 closure record](dual_lab_vrrp_ai_query_mvp.md#dl-04-reviewed-closure-candidate). +This status records separately authorized local work without changing historical +Stage-2 proof or closure semantics or reopening Stage 2. + ## 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. @@ -373,8 +396,19 @@ Lab1 and Lab2 VRRP read-only proofs and completed persistent Lab2 startup reconstruction. This candidate activates no new adapter, protocol, provider, credential, device, or execution capability and permits no repeat attempt. Formal closure still requires independent closure review and remote Safe CI. -Dual-Lab AI Query does not yet exist, and Stage 3 remains NOT STARTED. Further -live access or scope expansion requires the applicable capability gates and -separate exact Owner approval. +The complete Dual-Lab AI Query MVP remains unfinished: its exact local DL-02 +implementation has fresh independent review PASS and Owner acceptance ACCEPTED, +as recorded above. DL-02 and DL-03 are accepted but not integrated; DL-03 has +governed review PASS and completed Owner acceptance ACCEPTED. +DL-04 closure and fresh governed review passed; Owner acceptance completed +ACCEPT / PASS and `DL_04_ACCEPTED = YES`. Its one P3 argv-recording finding remains +OPEN_NON_BLOCKING; unresolved material findings are zero. The next Owner decision +is `AUTHORIZE_DL_05_READINESS_AND_P3_IMPACT_REVIEW`. Future DL-05 planning must +decide whether remediation is required before reusing the affected wrapper for +exact argv / command-attestation evidence. DL-05 remains NOT STARTED, with Safe CI +preserved for that separate gate; Safe CI has not run for DL-04 or DL-05. +Stage 3 remains NOT STARTED. Further live access or scope +expansion requires the applicable capability gates and separate exact Owner +approval; DL-02, DL-03, and DL-04 acceptance grants no live authority or readiness. This document does not start Phase 2C-10 or any implementation phase. It does not create a second safety matrix. It is a durable planning reference for future review and approval decisions. diff --git a/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md b/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md new file mode 100644 index 0000000..a252d01 --- /dev/null +++ b/docs/automation_readiness/dual_lab_vrrp_ai_query_mvp.md @@ -0,0 +1,2035 @@ +# Dual-Lab VRRP AI Query MVP: Accepted Predecessors and DL-05 Specification + +**Decision summary: DL-02 remains accepted; the DL-03 facts-only projection is +implemented, governed review passed, and Owner acceptance completed ACCEPT / PASS. +DL-04 closure execution completed PASS and fresh governed review passed with zero +material findings and one open non-blocking P3 finding. Owner acceptance completed +ACCEPT / PASS; DL-04 is accepted. The DL-05 integration and Safe CI specification +is established; DL-05 execution has not started.** +The exact accepted DL-02 implementation +commit is `bb39e8295a2fa5d69980396dbaf8c374c79a57b3`. DL-02 is not +integrated into main, merged, or released. The exact accepted DL-03 implementation +is `e4c529507c6f5a26a289af4f6d3bfc9e0b170c18`; it is not integrated, +merged, or released. +DL-04 adds no production implementation. DL-05 has not started, and the complete +Dual-Lab MVP remains unfinished. +No live authority or readiness is granted. + +The current next Owner decision is +`AUTHORIZE_DL_05_REMOTE_BRANCH_PUSH_AND_PR_CREATION`. The +[DL-05 canonical contract](#dl-05-canonical-integration-and-safe-ci-contract) +defines the separately gated future work. The +[accepted DL-04 closure record](#dl-04-reviewed-closure-candidate) identifies +the exact candidate and sealed evidence separately from this documentation commit. +The [DL-04 closure contract](#dl-04-canonical-closure-contract) remains unchanged +in scope and requirements. + +## Purpose and planning boundary + +This is the canonical repository record for the accepted DL-02 implementation, +its completed independent review and Owner acceptance, plus the DL-03 canonical +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. +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 only the DL-05 specification in this +canonical document and one local documentation commit. README, the automation +plan, source, tests, workflows, configuration, dependencies, sealed DL-04 evidence, +the external wrapper, and historical Stage-2 records remain unchanged. This task +performs no application validation, remote mutation, Safe CI, acceptance, or +P3 remediation, and reopens no accepted predecessor. + +In the retained DL-02 history, the pre-acceptance documentation commit +`76ce998531de59c201eaa6f123e57b1f9d364b19` established the candidate record and +resolved the documentary gap. The subsequent read-only acceptance retry returned +ACCEPT / PASS without changing source, tests, or documentation. That completed +decision remains unchanged. + +The accepted Dual-Lab VRRP AI Query MVP planning scope, reaffirmed by the Owner's +documentation reconciliation authorization, defines DL-02 as the fixed +synchronous orchestrator in +`validation_framework/dual_lab_vrrp_query_orchestrator.py`: structural preflight, +Lab1-before-Lab2 ordering, bounded per-target failure handling, the existing +S2-RO-11 boundary only, and distinct authorization proof. Retry, fallback, and +parallel execution are excluded. + +That planning scope assigns the detailed record to this document and status +summaries to [the automation plan](actual_automation_integration_plan.md) and +[README](../../README.md). Stage-2 historical proof and closure semantics remain +unchanged; this acceptance record does not resolve any Stage-2 closure gate. + +```text +STAGE2_FILES_REQUIRING_SEMANTIC_CHANGE = NONE +STAGE2_REOPEN_REQUIRED = NO +``` + +## Exact accepted implementation and reviewed candidate + +```text +DL_02_IMPLEMENTATION_CANDIDATE = YES +DL_02_CANDIDATE_COMMIT = bb39e8295a2fa5d69980396dbaf8c374c79a57b3 +DL_02_CANDIDATE_PARENT = 314e50ad847caf04a6a200199b63e1b3d3505f61 +DL_02_ACCEPTED_IMPLEMENTATION_COMMIT = bb39e8295a2fa5d69980396dbaf8c374c79a57b3 +DL_02_ACCEPTED_IMPLEMENTATION_PARENT = 314e50ad847caf04a6a200199b63e1b3d3505f61 +DL_02_CHANGED_PATH_COUNT = 2 +DL_02_CHANGED_PATHS = + validation_framework/dual_lab_vrrp_query_orchestrator.py + tests/dual_lab/test_dual_lab_vrrp_query_orchestrator.py +DL_02_SOURCE_SHA256 = a669ec055feabe3464a8dac15ba5a043fc9032a5bdc5931696e7b5ad8e7877c9 +DL_02_TEST_SHA256 = 5296035c6c40cabe1fae7963182245c29ad09a446bc3e8e39deb4b46560462e3 +``` + +The SHA256 values identify the raw filesystem bytes used by the independent +review. The two-path scope above describes the remediation commit, not the later +documentation reconciliation commit. Documentation changes do not amend or +replace the reviewed code candidate. + +## P1 finding and remediation + +The original pair-level authorization collision check depended on both target +preflights succeeding. A safely available identity could be lost when a target +failed a later local eligibility check, allowing the other target to reach +S2-RO-11 despite a duplicate authorization ID or reference. + +The remediation retains the identity from the existing strict envelope parser +independently of local execution eligibility. It uses that identity only to +determine pair-level distinctness before either target invocation. A duplicate +`authorization_id` or `authorization_ref` gives both targets +`PREFLIGHT_AUTHORIZATION_NOT_DISTINCT` and causes zero S2-RO-11 invocations. + +POLICY_2 remains intact: when identities are distinct, a locally rejected target +retains its bounded failure, while the other target may proceed exactly once +only if independently eligible. An identity that cannot safely be established +does not fabricate a pair collision. Retained identity grants no execution +eligibility and bypasses no signature, time, target/request binding, replay, +credential, pinned-host, or command-policy control. + +```text +DL_02_P1_FINDING = authorization identity collision could be lost when a target failed later local eligibility checks +DL_02_P1_REMEDIATION = pair identity retained independently from local execution eligibility for pair-level distinctness checking only +DL_02_PAIR_COLLISION_CATEGORY = PREFLIGHT_AUTHORIZATION_NOT_DISTINCT +DL_02_PAIR_COLLISION_ZERO_INVOCATIONS = YES +DL_02_POLICY_2_PRESERVED = YES +DL_02_NO_AUTHORIZATION_WEAKENING = YES +DL_02_NO_NEW_TRUST_BOUNDARY = YES +``` + +## Completed fresh independent review evidence + +These are the completed independent-review results accepted by the Owner as +evidence for this documentation task. They are not new validation runs or remote +Safe CI results. The review confirmed the exact commit, parent, two-file scope, +hashes, P1 remediation, adequate regression coverage, and preserved DL-02 +invariants. It left the repository unchanged and the worktree clean. + +| Validation | Collected | Passed | Failed | Errors | Accepted skips | Unexpected skips | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Focused orchestrator suite | 185 | 185 | 0 | 0 | 0 | 0 | +| Stage-2 offline suite | 1892 | 1890 | 0 | 0 | 2 | 0 | +| Full offline suite | 4461 | 4458 | 0 | 0 | 3 | 0 | + +The guard review matched exactly three proven pre-existing denials. New or +unclassified denials and DL-02-remediation-caused denials were both zero. +Report-index returned acceptable WARN with exit code 0: 13 optional missing +reports, zero failures, zero mandatory missing reports, and zero unknown results. + +```text +DL_02_FRESH_INDEPENDENT_REVIEW_RESULT = PASS +DL_02_FOCUSED_VALIDATION = 185 passed +DL_02_STAGE2_VALIDATION = 1890 passed / 2 accepted skips +DL_02_FULL_OFFLINE_VALIDATION = 4458 passed / 3 accepted skips +KNOWN_PRE_EXISTING_GUARD_DENIAL_COUNT = 3 +KNOWN_PRE_EXISTING_GUARD_DENIAL_SET_MATCH = YES +DL_02_NEW_GUARD_DENIALS = 0 +DL02_REMEDIATION_CAUSED_GUARD_DENIAL_COUNT = 0 +DL_02_REPORT_INDEX = acceptable WARN / 13 optional missing / no failures +``` + +## Preserved safety boundary + +The reviewed candidate remains synchronous, with Lab1 before Lab2 and both +structural preflights before the first invocation. It has no retry, fallback, +async, thread, pool, queue, scheduler, or public arbitrary executor. It uses only +the existing S2-RO-11 boundary and revalidates canonical success evidence. + +It makes no pair-health verdict, temporal-skew threshold, or simultaneous +snapshot claim, and exposes no raw device stdout or secrets. This record grants +no Lab1/Lab2 access, SSH/NETCONF/RESTCONF execution, provider/API/model operation, +secrets handling, configuration backup/change, production execution, worker, or +AI agent loop. It adds no second safety matrix. + +## Completed Owner acceptance and remaining gates + +```text +DL_02_OWNER_ACCEPTANCE = ACCEPTED +DL_02_ACCEPTED = YES +DL_02_ACCEPTANCE_RESULT = PASS +DL_02_ACCEPTANCE_BASIS = FRESH_INDEPENDENT_REVIEW_PASS_AND_OWNER_ACCEPTANCE +ACCEPTANCE_CANONICAL_STATE_HEAD = 76ce998531de59c201eaa6f123e57b1f9d364b19 +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +COMPLETE_DUAL_LAB_MVP = NO +``` + +Owner acceptance establishes that the exact implementation identified above +satisfies the defined DL-02 requirements. The accepted implementation identity +remains separate from the pre-acceptance and post-acceptance documentation +commits. The review results, hashes, and safety findings remain unchanged. + +Integration, remote availability, integrated-MVP Safe CI, further slices, live +readiness, and production readiness remain separate gates. None of those later +states, complete Dual-Lab MVP acceptance, or DL-05 completion is established here. +DL-03 Owner acceptance also completed, as recorded below. The subsequent DL-04 +readiness review and documentation-only specification authorization are recorded +in the DL-04 section; neither starts closure execution. + +## DL-03 accepted implementation and completed Owner acceptance + +The canonical specification was established by commit +`fe4877a6a9c812e1f8e03ccdaceff8528f9fba7a`. Its direct child below implements +only the deterministic facts-only projection and its tests. The two-path scope +and raw filesystem hashes identify the implementation candidate, separately +from this documentation commit; reconciliation does not amend the candidate. + +```text +DL_03_SPECIFICATION_STATUS = ESTABLISHED +DL_03_IMPLEMENTED = YES +DL_03_IMPLEMENTATION_STARTED = YES +DL_03_IMPLEMENTATION_CANDIDATE = YES +DL_03_CANDIDATE_COMMIT = e4c529507c6f5a26a289af4f6d3bfc9e0b170c18 +DL_03_CANDIDATE_PARENT = fe4877a6a9c812e1f8e03ccdaceff8528f9fba7a +DL_03_CHANGED_PATH_COUNT = 2 +DL_03_CHANGED_PATHS = + validation_framework/dual_lab_vrrp_query_summary.py + tests/dual_lab/test_dual_lab_vrrp_query_summary.py +DL_03_SOURCE_SHA256 = 54c35c63b43dacc5d7f5d50a468a4ffe27d9094504766a42f737234784dac234 +DL_03_TEST_SHA256 = 700c746c704bf2f51f2b9bff6fa0eb299493563b390a5272cf94bfc9e0dbd2c9 +DL_03_SCHEMA_VERSION = dual-lab-vrrp-query-summary.v1 +DL_03_OWNER_ACCEPTANCE = ACCEPTED +DL_03_ACCEPTANCE_RESULT = PASS +DL_03_ACCEPTED = YES +DL_03_STATUS = ACCEPTED +DL_03_ACCEPTED_IMPLEMENTATION_COMMIT = e4c529507c6f5a26a289af4f6d3bfc9e0b170c18 +DL_03_ACCEPTED_IMPLEMENTATION_PARENT = fe4877a6a9c812e1f8e03ccdaceff8528f9fba7a +DL_03_FRESH_GOVERNED_REVIEW_RESULT = PASS +DL_03_ACCEPTANCE_BASIS = GOVERNED_REVIEW_PASS_AND_OWNER_ACCEPTANCE +DL_03_ACCEPTANCE_CANONICAL_STATE_HEAD = 3709a9eded6cf66205a1ac7d4113cf2d06e433ea +DL_04_IMPLEMENTATION_STARTED = NO +COMPLETE_DUAL_LAB_MVP = NO +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +DL_03_POST_ACCEPTANCE_NEXT_OWNER_DECISION_HISTORICAL = REVIEW_CANONICAL_PLAN_AND_AUTHORIZE_DL_04_READINESS_OR_SPECIFICATION_WORK +``` + +The accepted source is the [DL-03 summary module](../../validation_framework/dual_lab_vrrp_query_summary.py), +with [DL-03 contract tests](../../tests/dual_lab/test_dual_lab_vrrp_query_summary.py). +It preserves the DL-01 aggregate schema and DL-02/Stage-2 behavior. It derives +observations only from canonical Lab1 and Lab2 results, preserves duplicates, +and emits no guessed duplicate pairing or prohibited inference. The normative +contract below remains unchanged. + +```text +DL_03_PURPOSE = DETERMINISTIC_FACTS_ONLY_PROJECTION +DL_03_EXECUTION_AUTHORITY = NONE +DL_03_MODEL_PROVIDER_INTEGRATION = NO +DL_01_AGGREGATE_SCHEMA_CHANGE_REQUIRED = NO +DL_02_CHANGE_REQUIRED = NO +STAGE2_CHANGE_REQUIRED = NO +CROSS_TARGET_OBSERVATIONS_SOURCE = DERIVED_FROM_CANONICAL_LAB1_AND_LAB2_RESULTS_ONLY +OBSERVATION_KIND_COUNT = 14 +RECORD_MATCHING_KEY = (instance_name, vrid) +DUPLICATES_PRESERVED = YES +DUPLICATE_KEYS_GUESSED_PAIRED = NO +PROHIBITED_INFERENCE_IMPLEMENTED = NO +``` + +### Retained DL-03 fresh review and validation evidence + +The completed governed review returned PASS with no findings and no repository +mutation. It performed fresh read-only inspection and fresh validation in the +same conversation. Here, "fresh independent review" describes that process; +it is not an attestation by a separate human, agent identity, or conversation. +These Owner-supplied accepted results are retained evidence, not new validation +runs, remote Safe CI, Owner acceptance, or integration evidence. + +| Validation | Collected | Passed | Failed | Errors | Established skips | Unexpected skips | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| DL-03 focused suite | 211 | 211 | 0 | 0 | 0 | 0 | +| Complete Dual-Lab suite | 652 | 652 | 0 | 0 | 0 | 0 | +| Stage-2 offline suite | 1892 | 1890 | 0 | 0 | 2 | 0 | +| Full offline suite | 4672 | 4669 | 0 | 0 | 3 | 0 | +| External review probes | 66 | 66 | 0 | 0 | 0 | 0 | + +The guard matched the established set of three pre-existing denials; new, +unclassified, and DL-03-caused denials were zero. All tracked repository files +remained unchanged during the fresh review. Report-index returned acceptable +WARN, exit code 0, with 13 optional missing reports, zero mandatory missing +reports, zero failures, and zero unknown results. These optional runtime-report +gaps do not establish a safety or regression issue. + +```text +DL_03_FRESH_INDEPENDENT_REVIEW_RESULT = PASS +DL_03_REVIEW_FINDINGS = NONE +DL_03_REVIEW_REPOSITORY_MUTATION = NONE +DL_03_FOCUSED_VALIDATION = 211 passed +DL_03_DUAL_LAB_VALIDATION = 652 passed +DL_03_STAGE2_VALIDATION = 1890 passed / 2 established skips +DL_03_FULL_OFFLINE_VALIDATION = 4669 passed / 3 established skips +DL_03_EXTERNAL_REVIEW_PROBES = 66 passed +DL_03_KNOWN_PRE_EXISTING_GUARD_DENIAL_COUNT = 3 +DL_03_KNOWN_PRE_EXISTING_GUARD_DENIAL_SET_MATCH = YES +DL_03_NEW_GUARD_DENIALS = 0 +DL_03_NEW_OR_UNCLASSIFIED_GUARD_DENIAL_COUNT = 0 +DL_03_CAUSED_GUARD_DENIAL_COUNT = 0 +DL_03_REPORT_INDEX = acceptable WARN / 13 optional missing / zero failures +DL_03_REPORT_INDEX_EXIT_CODE = 0 +DL_03_REPORT_INDEX_MANDATORY_MISSING = 0 +DL_03_REPORT_INDEX_UNKNOWN = 0 +``` + +### Completed acceptance decision and limits + +The pre-acceptance candidate documentation commit +`3709a9eded6cf66205a1ac7d4113cf2d06e433ea` recorded the reviewed candidate +with Owner acceptance pending. The subsequent authorized read-only decision +returned `OWNER_ACCEPTANCE_DECISION = ACCEPT`, `ACCEPTANCE_RESULT = PASS`, +and `DL_03_ACCEPTED = YES`, with no repository mutation or validation rerun. +This reconciliation records that completed decision without repeating it. + +Acceptance establishes only that implementation +`e4c529507c6f5a26a289af4f6d3bfc9e0b170c18` satisfies the canonical DL-03 +specification and governed-review requirements. Its identity remains separate +from the pre-acceptance and post-acceptance documentation commits. The retained +review results, hashes, and same-conversation process meaning remain unchanged. + +This record grants no Lab1/Lab2 access, +execution authority, configuration mutation, live or production readiness, +complete-MVP acceptance, integration into main, remote Safe CI completion, or +DL-04 implementation authority. + +## DL-03 canonical specification: purpose and scope + +The following specification is retained from its establishment commit. Its +references to "future" implementation describe that original specification +boundary; the current implemented, reviewed, Owner-accepted state is +recorded above. The contract and validation requirements are not broadened. + +DL-03 projects an already canonical Dual-Lab aggregate into an immutable, +deterministic AI/UI-facing summary DTO. Its only claims are observed values, +availability, existing sanitized failure categories, equality/difference, set +membership/difference, and record counts. AI/UI-facing describes a data consumer; +it introduces no model or provider integration. + +This specification was established against repository baseline +`4d0abf0fd87986cb3fb054f9e265f5846ba52b47`. The normative upstream boundaries are +[DL-01's inert aggregate contract](../../validation_framework/dual_lab_vrrp_query_contract.py) +and [the inert Stage-2 evidence contract](../../validation_framework/stage2_vrrp_readonly_contract.py). +The former validates the fixed target pair and tagged results; the latter +validates normalized records. DL-03 consumes those boundaries without changing +their schemas or execution semantics. + +```text +DL_03_PURPOSE = DETERMINISTIC_FACTS_ONLY_PROJECTION +DL_03_EXECUTION_AUTHORITY = NONE +DL_03_MODEL_PROVIDER_INTEGRATION = NO +DL_03_INPUT = EXISTING_CANONICAL_DUAL_LAB_AGGREGATE_ONLY +DL_03_OUTPUT = IMMUTABLE_FACTS_ONLY_SUMMARY_DTO +DL_03_SCHEMA_VERSION = dual-lab-vrrp-query-summary.v1 +DL_01_AGGREGATE_SCHEMA_CHANGE_REQUIRED = NO +DL_01_CHANGE_REQUIRED = NO +DL_02_CHANGE_REQUIRED = NO +STAGE2_CHANGE_REQUIRED = NO +``` + +### Exact public API and input boundary + +The future module's public exports are exactly `DualLabVrrpSummary`, +`DualLabSummaryError`, `project_dual_lab_vrrp_summary`, and +`parse_summary_canonical_json`. The two functions have these signatures: + +```python +project_dual_lab_vrrp_summary(raw: bytes) -> DualLabVrrpSummary +parse_summary_canonical_json(raw: bytes) -> DualLabVrrpSummary +``` + +`project_dual_lab_vrrp_summary` accepts only exact built-in `bytes`, containing +the existing DL-01 aggregate's canonical JSON. It delegates validation to +`parse_aggregate_canonical_json` before deriving any output. Callers holding a +`DualLabVrrpAggregate` use its existing `to_canonical_bytes()` first. There is no +duck-typed object, dictionary, text, bytearray, subclass, runtime bundle, or +executor input overload. Forged objects therefore cannot bypass validation. + +The input has exactly `schema_version`, `query_id`, `operation_id`, +`execution_order`, `lab1`, and `lab2`. Its schema remains +`dual-lab-vrrp-query.v1`; operation is `mikrotik.vrrp_status`; execution order is +`LAB1_THEN_LAB2`. Lab1 is bound to `target.mikrotik.lab01`, Lab2 to +`target.mikrotik.lab02`. Query identity retains DL-01's grammar and 160-character +bound. Success input carries canonical `evidence`; failure input carries the +existing bounded `failure_category`. All nested exact-field, value, target, +canonical JSON, and size checks remain upstream checks. The upstream aggregate +byte limit is 69,632 bytes. No new input field is permitted, including +`cross_target_observations`. + +Malformed, noncanonical, extra-field, target-swapped, or invalid-success input +fails closed before projection. Invalid input is not repaired, normalized into +validity, or converted into a target failure. Contract validation establishes +data validity, not provenance, freshness, authorization, or device truth. + +`parse_summary_canonical_json` accepts only exact built-in `bytes` containing +canonical summary JSON conforming to the output rules below. It does not accept +an aggregate, acquire evidence, +or call the projection entrypoint. It validates the entire DTO and recomputes +the required observations from its target projections, rejecting inconsistent, +missing, extra, or differently ordered observations. A parsed summary conveys +no stronger provenance than its data. + +`DualLabVrrpSummary` is factory-created by those functions; direct construction +is not a public API. Its public data attributes are the seven output fields. +Its public methods are `to_dict() -> dict[str, object]` and +`to_canonical_bytes() -> bytes`; its nonserialized `execution_authorized` +property always returns `False`. Both methods revalidate all fields and derived +observations, including after deliberate frozen-object tampering. Nested +representations are private immutable value objects, with fields exactly as +specified below; arrays are tuples internally and lists only in fresh exports. + +All public rejection paths raise `DualLabSummaryError`, a `ValueError` with +exact message `invalid Dual-Lab summary`. The error retains no input, child +exception, cause, or context. No partial DTO is returned. DTO and nested-object +`repr` and `str` return the fixed label ``. + +### Exact output and projected records + +Every object is closed to extra or missing fields. Primitive types are exact; +booleans cannot substitute for integers. The top-level fields are exactly: + +| Field | Required value | +| --- | --- | +| `schema_version` | `dual-lab-vrrp-query-summary.v1` | +| `query_id` | Validated input query identity, unchanged | +| `operation_id` | `mikrotik.vrrp_status`, unchanged | +| `execution_order` | `LAB1_THEN_LAB2`, unchanged; metadata only | +| `lab1` | Projection bound to `target.mikrotik.lab01` | +| `lab2` | Projection bound to `target.mikrotik.lab02` | +| `cross_target_observations` | Ordered array derived by the rules below | + +A successful target has exactly `status`, `target_ref`, `records`, with status +`SUCCESS`. A failed target has exactly `status`, `target_ref`, +`failure_category`, with status `FAILURE`. Failure has **no `records` field**: +neither null nor an empty array is valid there. Success has 0 through 32 records; +an empty successful observation remains distinct from unavailable evidence. + +Each projected record has exactly these nine observed fields, retaining the +upstream types and bounds without coercion, case folding, trimming, inference, +or substitutions: + +| Field | Exact domain | +| --- | --- | +| `instance_name` | Nonempty NFC string; no surrounding whitespace or Unicode category C characters; at most 128 UTF-8 bytes | +| `role` | `MASTER`, `BACKUP`, `FAILURE`, or `UNKNOWN` | +| `vrid` | Integer 1 through 255 | +| `priority` | Integer 0 through 255 | +| `interval_ms` | Integer 1 through 255,000 | +| `version` | Integer 2 or 3 | +| `running` | Boolean | +| `disabled` | Boolean | +| `invalid` | Boolean | + +No evidence-envelope fields are copied into a success projection. In particular, +run IDs, authorization IDs/references, policy versions, attempt/retry counts, +durations, raw-output byte counts, and raw-output hashes are excluded. Raw +stdout, credentials/references, signatures, private keys, known-host raw data, +private paths, replay data, exception text, runtime configuration, and SSH/session +details have no output field. `target_ref` is the existing fixed public logical +identifier, not an endpoint or credential reference. + +### Record order, matching, and duplicate preservation + +Sort each target's records ascending by the complete key: + +```text +(instance_name, vrid, role, priority, interval_ms, version, running, disabled, invalid) +``` + +Strings use Python Unicode code-point order, integers numeric order, and booleans +`False` before `True`. The primary key is `(instance_name, vrid)`; the remaining +seven observable fields form the complete tie-breaker. Byte-identical duplicates +remain repeated entries. Upstream permits duplicate matching keys and identical +records, so DL-03 must neither reject valid duplicates nor collapse them. + +Group records on each side by exact `(instance_name, vrid)`. Field comparisons +are emitted only when that key has exactly one record on each side. A common key +with multiplicity greater than one on either side yields one +`MATCH_KEY_MULTIPLICITY` observation with the two counts; it yields no field +comparison. This reports the ambiguity without inventing a record pairing. + +A key occurring only on one side yields an unmatched-key observation with its +occurrence count, including all duplicates. It is never matched by array +position, role, priority, similar spelling, or VRID alone. Sorting does not +establish a cross-target pairing. + +```text +RECORD_MATCHING_KEY = (instance_name, vrid) +INPUT_ORDER_INDEPENDENT = YES +DUPLICATE_RECORD_COLLAPSE = NO +DETERMINISTIC_OUTPUT = YES +``` + +Equivalent inputs with the same query identity, target statuses/failure labels, +and per-target record multisets produce identical output bytes. Changes only +to valid upstream evidence-envelope metadata also cannot affect the summary. +Canonical input bytes may contain any valid record-array order; DL-03 sorts its +output without rewriting or relaxing upstream canonical JSON validation. + +### Closed cross-target observation vocabulary + +```text +CROSS_TARGET_OBSERVATIONS_SOURCE = DERIVED_FROM_CANONICAL_LAB1_AND_LAB2_RESULTS_ONLY +CROSS_TARGET_OBSERVATION_SCHEMA = kind, subject, lab1_value, lab2_value +``` + +Every observation contains exactly those four fields. `kind` is one of the 14 +literal strings in the table. `subject` and both values are constrained by its +row; there is no free-text explanation, arbitrary mapping, or extension kind. +`K` means an exact object with `instance_name` and `vrid`, using record domains. +`KF` means an exact object with `instance_name`, `vrid`, and `field`. `field` is +one of `role`, `priority`, `interval_ms`, `version`, `running`, `disabled`, +`invalid`. Array values are sorted unique sets, not record arrays. + +| Permitted kind | Exact subject | Exact lab1_value / lab2_value | Emission condition | +| --- | --- | --- | --- | +| `RESULT_AVAILABILITY` | `null` | Boolean / boolean | Always once; true iff that target is SUCCESS | +| `VRID_SET_EQUAL` | `null` | Integer array / integer array | Both succeed and observed VRID sets equal | +| `VRID_SET_DIFFERENT` | `null` | Integer array / integer array | Both succeed and observed VRID sets differ | +| `VRID_ONLY_LAB1` | Integer VRID | `true` / `false` | One per VRID in Lab1 set minus Lab2 set | +| `VRID_ONLY_LAB2` | Integer VRID | `false` / `true` | One per VRID in Lab2 set minus Lab1 set | +| `INSTANCE_NAME_SET_EQUAL` | `null` | String array / string array | Both succeed and observed name sets equal | +| `INSTANCE_NAME_SET_DIFFERENT` | `null` | String array / string array | Both succeed and observed name sets differ | +| `RECORD_COUNT_EQUAL` | `null` | Integer count / integer count | Both succeed and full record counts equal | +| `RECORD_COUNT_DIFFERENT` | `null` | Integer count / integer count | Both succeed and full record counts differ | +| `RECORD_ONLY_LAB1` | `K` | Positive count / `0` | Key exists only in Lab1 | +| `RECORD_ONLY_LAB2` | `K` | `0` / positive count | Key exists only in Lab2 | +| `MATCH_KEY_MULTIPLICITY` | `K` | Positive count / positive count | Common key has count greater than one on either side | +| `FIELD_EQUAL` | `KF` | Observed scalar / observed scalar | Unique key on each side, selected field equal | +| `FIELD_DIFFERENT` | `KF` | Observed scalar / observed scalar | Unique key on each side, selected field different | + +All rows other than availability require both targets to succeed. Counts range +from 0 through 32, or 1 through 32 where positive is specified. VRID and name +arrays contain at most 32 entries in their upstream domains. Field values have +exactly the selected field's domain. Set comparisons intentionally remove +duplicate set members; record projections and record counts never do. + +The observation array has this exact order, with no optional rows: + +1. One `RESULT_AVAILABILITY` row. +2. If either target failed, stop: the array contains only that row. +3. Exactly one VRID set equality/difference row; then all `VRID_ONLY_LAB1` + rows ascending by VRID; then all `VRID_ONLY_LAB2` rows ascending by VRID. +4. Exactly one instance-name set equality/difference row. +5. Exactly one full record-count equality/difference row. +6. All `RECORD_ONLY_LAB1` rows ascending by K; then all `RECORD_ONLY_LAB2` + rows ascending by K. +7. All `MATCH_KEY_MULTIPLICITY` rows ascending by K. +8. For every key unique on both sides, ascending by K, exactly seven field + equality/difference rows in this field order: `role`, `priority`, + `interval_ms`, `version`, `running`, `disabled`, `invalid`. + +K uses the record primary sort order. Field equality is exact typed equality, +not health evaluation. Equal priorities yield `FIELD_EQUAL`; different priorities +yield `FIELD_DIFFERENT`; neither produces a recommendation. No timestamp, skew, +convergence, paired-health, or simultaneous-snapshot claim is available. + +For example, two successful empty results produce exactly four rows: +availability `(true, true)`, VRID-set equality `([], [])`, name-set equality +`([], [])`, and count equality `(0, 0)`. Two identical single records produce +those four kinds of rows followed by seven `FIELD_EQUAL` rows. A shared key +with two Lab1 records and one Lab2 record emits multiplicity `(2, 1)` and zero +field rows for that key, retaining all three projected records. + +### Failures and prohibited inference + +Failure projection preserves the fixed target and exact upstream enum spelling: + +```text +PREFLIGHT_TARGET_BUNDLE_INVALID +PREFLIGHT_AUTHORIZATION_INVALID +PREFLIGHT_AUTHORIZATION_NOT_DISTINCT +PREFLIGHT_STARTUP_BINDING_UNAVAILABLE +CANONICAL_EVIDENCE_VALIDATION_FAILED +INVALID_REQUEST_INPUT +INVALID_AUTHORIZATION_INPUT +INVALID_TRUSTED_CONFIGURATION +TRUSTED_RUNTIME_FAILED +OUTPUT_RENDER_FAILED +INTERNAL_FAILURE +``` + +Lab1-success/Lab2-failure yields only availability `(true, false)`; the reverse +yields `(false, true)`; both failures yield `(false, false)`. The successful +target's records are still projected and sorted. No comparison involving the +unavailable target is emitted: not even a zero count or empty set. There is no +historical substitution, copied peer evidence, fabricated record, inferred +failure explanation, or retry/fallback advice. + +Closed fields and discriminated value domains make the DTO incapable of issuing +health, readiness, configuration-correctness, root-cause, or remediation +verdicts. In particular, no status or kind may be `HEALTHY`, `UNHEALTHY`, +`DEGRADED`, `FAILOVER_READY`, `HA_READY`, `PRODUCTION_READY`, `SPLIT_BRAIN`, +`MISCONFIGURED`, `CORRECT_CONFIGURATION`, `INCORRECT_CONFIGURATION`, +`ROOT_CAUSE`, or `REMEDIATION_REQUIRED`. + +There are no remediation/configuration command, retry/fallback advice, +credential/endpoint selection, authorization decision, command selection, or +execution-instruction fields. A valid observed instance name is opaque data, +even if it spells a prohibited verdict or resembles a command. Preserve it as +data; never interpret or render it as a verdict or executable instruction. +This distinction preserves upstream compatibility without allowing free-form +claims in structural fields. + +### Canonical serialization and isolation + +Use the existing JSON convention: `json.dumps` with `allow_nan=False`, +`ensure_ascii=False`, `separators=(",", ":")`, `sort_keys=True`, then strict +UTF-8 encoding. No BOM, trailing newline, whitespace variation, alternate key +ordering, duplicate JSON keys at any depth, nonfinite numbers, coercion, or +extra fields are accepted by the summary parser. Re-encoding must equal the +input bytes exactly. Record and observation arrays must already have their +specified order when parsing a summary; the parser rejects rather than repairs +unsorted summaries. The projection entrypoint is responsible for sorting valid +aggregate records. + +The summary canonical byte limit is 262,144 bytes; observations are limited to +356 rows (a conservative bound: 4 base rows, up to 64 VRID-only rows, up to 64 +key-level rows, and up to 224 field rows). Upstream record/name limits keep all +valid derived summaries within this byte bound, including JSON escaping of +128-byte names. These output limits do not change upstream limits. + +All nested DTO values are immutable and detached from upstream objects. Every +`to_dict()` returns fresh dictionaries/lists throughout. Mutating an exported +dictionary cannot change the DTO, another export, or later bytes. Serialization +and parsing enforce the full exact schema and derived-row consistency. They +introduce no alternative serialization framework or dependency. + +```text +CANONICAL_SERIALIZATION_REQUIRED = YES +IMMUTABLE_DTO_REQUIRED = YES +EXACT_FIELD_VALIDATION_REQUIRED = YES +DUPLICATE_JSON_KEY_REJECTION_REQUIRED = YES +INPUT_OUTPUT_ISOLATION_REQUIRED = YES +``` + +### Future implementation boundary and validation contract + +Only the following two new files are the canonical scope for a separately +authorized implementation. They are not created by this specification task. + +```text +DL_03_AUTHORIZED_SOURCE_FILES = validation_framework/dual_lab_vrrp_query_summary.py +DL_03_AUTHORIZED_TEST_FILES = tests/dual_lab/test_dual_lab_vrrp_query_summary.py +EXISTING_SOURCE_FILES_TO_MODIFY = NONE +EXISTING_TEST_FILES_TO_MODIFY = NONE +DEPENDENCY_CHANGE_REQUIRED = NO +``` + +The future module may reuse the inert DL-01 and Stage-2 contract modules and +standard-library data/JSON facilities. It must not import or invoke S2-RO-11, +the DL-02 orchestrator, runners, adapters, brokers, or live entrypoints. No +network query, Lab1/Lab2 access, credential resolution, authorization acquisition +or signing, replay consumption, command selection, or trusted runtime-config +access is permitted. No SSH, NETCONF, RESTCONF, external API/provider/model, +queue, scheduler, worker, AI loop, config backup/change, or production execution +path is added. No CLI/task-registry/report renderer/dashboard integration, +Day1-Day160 rewrite, second safety matrix, or next-slice implementation belongs +to this scope. If implementation cannot satisfy the contract in these two new +files, stop and report the exact conflict; do not broaden the scope. + +The future test file must establish the following deterministic assertions. +These are acceptance tests for the future implementation, not tests run by +this documentation task. + +| Test | Required assertion | +| ---: | --- | +| 1 | Both targets SUCCESS: exact target projections and complete ordered derived rows. | +| 2 | Lab1 SUCCESS / Lab2 FAILURE: availability true/false only; Lab1 records retained. | +| 3 | Lab1 FAILURE / Lab2 SUCCESS: availability false/true only; Lab2 records retained. | +| 4 | Both FAILURE: availability false/false only; exact failure categories retained. | +| 5 | Both SUCCESS with zero records: exactly the four rows specified above. | +| 6 | Identical single records: exactly eleven rows, seven field equalities. | +| 7 | Multiple records: all records retained and sorted by the full key. | +| 8 | Reverse/shuffle either target's records: identical summary canonical bytes. | +| 9 | Repeat equivalent input, reorder input object keys before canonical encoding, or vary valid discarded envelope metadata: identical output bytes. | +| 10 | Exactly seven top-level fields; missing/extra fields reject in summary parsing. | +| 11 | Exact success/failure field sets; failure records absent, null/empty variants reject. | +| 12 | Exactly nine record fields; wrong domains, missing/extra fields reject. | +| 13 | Upstream envelope metadata excluded; sensitive/raw field injection rejected at every schema depth; sanitized errors retain no rejected input or child exception. | +| 14 | Same VRID sets: one VRID_SET_EQUAL row with sorted unique sets. | +| 15 | Different VRID sets: one VRID_SET_DIFFERENT and exact directional VRID-only rows. | +| 16 | Lab1-only record keys: RECORD_ONLY_LAB1 with full occurrence count and peer zero. | +| 17 | Lab2-only record keys: RECORD_ONLY_LAB2 with peer zero and full occurrence count. | +| 18 | Unique exact matched key with equal fields: seven ordered FIELD_EQUAL rows. | +| 19 | Change each comparable field independently: exactly that field's row becomes FIELD_DIFFERENT with exact values. | +| 20 | Equal priority values: factual equality only, no role/health inference. | +| 21 | Different priority values: factual difference only, no preference/advice. | +| 22 | All boolean flag combinations compare exact booleans; integers reject as flags. | +| 23 | Same VRID/different name, same name/different VRID, similar names, positions, or priorities never establish a match. | +| 24 | Identical and differing duplicates survive; complete tie-breakers are stable; common duplicate key yields counts and no field pairing. | +| 25 | Failed target never receives records, historical/peer substitution, zero-count, or empty-set comparisons. | +| 26 | Malformed/noncanonical/oversize aggregate bytes, wrong input types, subclasses, and forged objects reject with no DTO. | +| 27 | Extra aggregate fields, especially cross_target_observations, reject through DL-01 validation. | +| 28 | Swapped targets or mismatched target/evidence bindings reject. | +| 29 | Invalid success evidence, including forged/out-of-domain record values, rejects. | +| 30 | Health/readiness/configuration verdict kinds and fields reject; verdict-like instance names remain opaque observed data. | +| 31 | Root-cause kinds/fields reject. | +| 32 | Remediation kinds/fields reject. | +| 33 | Configuration command/instruction kinds/fields reject; command-like names remain inert data. | +| 34 | Retry/fallback advice kinds/fields reject. | +| 35 | Import and all public valid/rejected paths make zero execution/network calls and no application file/runtime I/O beyond normal Python module loading; existing execution boundaries are unreachable. | +| 36 | Import and all public paths make zero provider/model calls and load no provider integration. | +| 37 | Same/different instance-name sets yield exact sorted set rows, including empty sets. | +| 38 | Equal/different full counts include duplicates; all 11 failure categories preserve exact spelling. | +| 39 | Canonical summary round-trip; duplicate JSON keys at every depth, wrong byte framing, unsorted arrays, altered/missing/extra/inconsistent derived rows reject. | +| 40 | Frozen nested DTOs, fresh-export isolation, and revalidation after tampering; execution_authorized remains false. | +| 41 | Maximum 32 records per target, maximum UTF-8 names with escaping, Unicode ordering, and duplicate-heavy inputs stay within output bounds. | +| 42 | Exact public export set and byte-only signatures; no executor, callback, runtime-config, or authorization argument. | + +Future validation must include +`python -m pytest tests/dual_lab/test_dual_lab_vrrp_query_summary.py`, +`python -m pytest tests/dual_lab/test_dual_lab_vrrp_query_contract.py`, +`python -m pytest`, and `python network_lab.py --task report-index` under the +repository's offline safety controls. Negative cases must demonstrate zero +execution, not merely an error result. No dedicated runner is introduced. +Record exact counts, skips, guard outcomes, and any optional-report WARN; none +of those results is pre-approved by this specification. + +### Specification decision and documentation review + +The specification's consistency/readability review confirmed distinct summary +and input schemas, closed observation types and ordering, duplicate handling +compatible with upstream, explicit failure absence, and a scope of two new +files. The accepted DL-02 identity, review evidence, and Stage-2 +closure remain unchanged. The current documentation reconciliation aligns this +record, README, and the automation plan on DL-03 implemented / governed review PASS / +Owner acceptance ACCEPTED. The conclusion, allowed documentation scope, excluded +execution scope, accepted implementation identity, retained evidence, and next +Owner decision are explicit. This documentation review changes no runtime behavior or +specification requirement and creates no second safety matrix. + +```text +DL_02_ACCEPTED = YES +DL_03_SPECIFICATION_STATUS = ESTABLISHED +DL_03_IMPLEMENTED = YES +DL_03_FRESH_INDEPENDENT_REVIEW_RESULT = PASS +DL_03_FRESH_GOVERNED_REVIEW_RESULT = PASS +DL_03_OWNER_ACCEPTANCE = ACCEPTED +DL_03_ACCEPTANCE_RESULT = PASS +DL_03_ACCEPTED = YES +DL_04_IMPLEMENTATION_STARTED = NO +MINIMUM_TEST_MATRIX_ESTABLISHED = YES +DOCUMENTATION_CONSISTENCY_REVIEW = PASS +DOCUMENTATION_READABILITY_REVIEW = PASS +COMPLETE_DUAL_LAB_MVP = NO +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +DL_03_POST_ACCEPTANCE_NEXT_OWNER_DECISION_HISTORICAL = REVIEW_CANONICAL_PLAN_AND_AUTHORIZE_DL_04_READINESS_OR_SPECIFICATION_WORK +``` + +## DL-04 reviewed closure candidate + +This retained post-acceptance record describes the reconciliation at +`d3216f774d28e84b828355a727bc9807f1ce3b46`. References below to that +reconciliation and its next decision are historical; current DL-05 status and +authority are defined in the [DL-05 contract](#dl-05-canonical-integration-and-safe-ci-contract). +Acceptance, candidate identity, evidence, and the open finding remain unchanged. + +**DL-04 is accepted: closure PASS, fresh governed review PASS, and completed +Owner acceptance ACCEPT / PASS.** The completed review found zero unresolved +material findings and exactly one open non-blocking P3 finding. This +documentation-only descendant records the completed decision; the accepted +closure candidate and sealed evidence retain their exact identities. + +### Exact candidate, evidence identity, and current status + +```text +DL_02_ACCEPTED = YES +DL_03_ACCEPTED = YES +DL_04_SPECIFICATION_STATUS = ESTABLISHED +DL_04_SPECIFICATION_COMMIT = 0972910c4d5e2bb7d70acbb50c4721234f20d0dc +DL_04_CLOSURE_EXECUTION_STARTED = YES +DL_04_CLOSURE_EXECUTION_COMPLETED = YES +DL_04_CLOSURE_RESULT = PASS +DL_04_CLOSURE_CANDIDATE = YES +DL_04_CANDIDATE_COMMIT = 0972910c4d5e2bb7d70acbb50c4721234f20d0dc +DL_04_CANDIDATE_PARENT = d3bbe767fa5653bb7ee213594959877fd4ff80fe +DL_04_MANIFEST_SHA256 = f7dd14e1ea471032cf68e108d2ea85b144096d28f3aa6c339efd89bf27567449 +DL_04_FRESH_GOVERNED_REVIEW_RESULT = PASS +DL_04_REVIEW_MATERIAL_FINDINGS = 0 +DL_04_REVIEW_NONBLOCKING_FINDINGS = 1 +DL_04_OWNER_ACCEPTANCE = ACCEPTED +DL_04_ACCEPTANCE_RESULT = PASS +DL_04_ACCEPTED = YES +DL_04_STATUS = ACCEPTED +DL_04_ACCEPTED_CLOSURE_CANDIDATE = 0972910c4d5e2bb7d70acbb50c4721234f20d0dc +DL_04_ACCEPTED_CLOSURE_PARENT = d3bbe767fa5653bb7ee213594959877fd4ff80fe +DL_04_ACCEPTED_MANIFEST_SHA256 = f7dd14e1ea471032cf68e108d2ea85b144096d28f3aa6c339efd89bf27567449 +DL_04_ACCEPTANCE_BASIS = CLOSURE_PASS_PLUS_FRESH_GOVERNED_REVIEW_PASS_PLUS_OWNER_ACCEPTANCE +DL_04_ACCEPTANCE_CANONICAL_STATE_HEAD = d921385021d21761af2aef81c9ec7e3d283123e9 +DL_04_SAFE_CI_REQUIRED = NO +SAFE_CI_RUN = NO +DL_05_SAFE_CI_BOUNDARY_PRESERVED = YES +DL_05_STARTED = NO +COMPLETE_DUAL_LAB_MVP = NO +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +DL_04_POST_ACCEPTANCE_NEXT_OWNER_DECISION_HISTORICAL = AUTHORIZE_DL_05_READINESS_AND_P3_IMPACT_REVIEW +``` + +The sealed external execution is identified by run ID +`nal_dl04_closure_20261002_01`. Its evidence root is represented here as +`/evidence`; its physical retention path stays outside the public +repository. The root contains `candidate-manifest.json` and +`dl04-closure-result.json`; `manifest-receipt.json` is retained beside the evidence +root. The receipt binds the exact manifest hash above to the candidate and parent. +The specification and candidate identities are equal; the comparison base equals +the parent. The pre-acceptance canonical state commit +`d921385021d21761af2aef81c9ec7e3d283123e9` has that candidate as its direct +parent. This post-acceptance documentation commit descends directly from that +canonical state commit; its actual hash is reported after commit. The accepted +closure candidate remains `0972910c4d5e2bb7d70acbb50c4721234f20d0dc`. + +Owner acceptance completed ACCEPT / PASS against that canonical state and the +exact candidate and manifest above. The completed decision is retained in the +authorized Owner-acceptance response in the same conversation and reaffirmed by +the post-acceptance reconciliation authorization. No repository or evidence +mutation occurred during acceptance. This task records that decision only. + +The completed fresh review established exact manifest hash/candidate binding, +valid provenance, all 14 required artifacts present, and all 26 sealed evidence +files unchanged. Fresh execution results were distinguished from historical +comparison inputs. All 1,021 tracked candidate files retained their raw bytes +during closure execution and review. These are retained results for the candidate, +not a claim that this later documentation commit was closure-tested. + +The completed governed review is recorded in the preceding authorized review +response in the same conversation and reaffirmed by the Owner's reconciliation +authorization. It was fresh technical/governed inspection of the exact candidate +and frozen evidence, with read-only checks and no required validation rerun. It +was not a separate-human, separate-agent-identity, or separate-conversation +attestation. Those process limits do not change its PASS result. This record does +not claim a new external `governed-review.json` or Owner-acceptance receipt was +created. The sealed execution artifact correctly retains its earlier +`PENDING_FRESH_GOVERNED_REVIEW` status; this later canonical record records the +completed review and Owner acceptance without altering that frozen artifact. + +### Reviewed fresh closure results + +These results belong to the completed fresh DL-04 execution. They are recorded +from the sealed bundle and completed governed review, not rerun by this task. +Use the [canonical command map](#candidate-identity-and-deterministic-offline-command-map) +and manifest index to navigate to the exact suite evidence. + +| Validation | Collected | Passed | Established skips | Unexpected skips | Failures / errors | Logical artifact | +| --- | ---: | ---: | ---: | ---: | --- | --- | +| DL-01 focused | 256 | 256 | 0 | 0 | 0 / 0 | `focused-dl01.json` | +| DL-02 focused | 185 | 185 | 0 | 0 | 0 / 0 | `focused-dl02.json` | +| DL-03 focused | 211 | 211 | 0 | 0 | 0 / 0 | `focused-dl03.json` | +| Complete Dual-Lab | 652 | 652 | 0 | 0 | 0 / 0 | `dual-lab.json` | +| Stage-2 offline | 1892 | 1890 | 2 | 0 | 0 / 0 | `stage2.json` | +| Full offline | 4672 | 4669 | 3 | 0 | 0 / 0 | `full-pytest.json` | + +All six exact workflow/governance proofs passed (`workflow-proofs.json`), and all +28 canonical coverage areas passed (`requirement-to-evidence.json`). The review +checked suite membership and test-level outcomes, exact established skip +identities, source fingerprints, denial causality, and requirement mappings; +matching totals alone were not the basis for PASS. + +```text +WORKFLOW_REQUIRED_PROOF_COUNT = 6 +WORKFLOW_REQUIRED_PROOFS_PASSED = 6 +CANONICAL_COVERAGE_AREA_COUNT = 28 +CANONICAL_COVERAGE_AREAS_PASSED = 28 +KNOWN_PRE_EXISTING_GUARD_DENIAL_COUNT = 3 +KNOWN_PRE_EXISTING_GUARD_DENIAL_SET_MATCH = YES +NEW_OR_UNCLASSIFIED_GUARD_DENIAL_COUNT = 0 +DL04_CAUSED_GUARD_DENIAL_COUNT = 0 +REPORT_INDEX_RESULT = WARN +REPORT_INDEX_EXIT_CODE = 0 +REPORT_INDEX_FAILURE_COUNT = 0 +REPORT_INDEX_OPTIONAL_MISSING_COUNT = 13 +REPORT_INDEX_MANDATORY_MISSING_COUNT = 0 +REPORT_INDEX_UNKNOWN_COUNT = 0 +REPORT_INDEX_WARN_ACCEPTABLE = YES +``` + +The guard counter covers the top-level parent process. The exact classifications +remain in `skip-classification.json` and `guard-classification.json`. Each missing +report in `report-index.log` and `report-index.json` was verified optional under +the unchanged registry. The WARN does not establish a safety or regression issue. + +The candidate-bound security review's sixteen assertions and six preservation +checks passed. Stage-2 authority, Owner authorization, replay, credential, +pinned-host, and exact read-only command boundaries remain unchanged. Pair +identity collision precedence, rejected-target zero invocation, canonical +evidence revalidation, closed immutable schemas, and sanitized failures remain +supported. No secret or raw live data was found in the evidence. The external +preparation command-length failure occurred before process startup, affected no +repository state, and did not contaminate accepted evidence. Read-only review +probe corrections likewise changed no repository or sealed evidence files. + +### Open non-blocking review finding + +```text +FINDING_ID = DL04_REVIEW_P3_PYTEST_ARGV_RECORDING +SEVERITY = P3 +TITLE = Preserve non-path arguments in recorded pytest argv +LOCATION = external closure wrapper run_review.py, reviewed line 67 +CLASSIFICATION = NON_BLOCKING +MATERIAL_FINDING = NO +CLOSURE_INVALIDATED = NO +SEALED_EVIDENCE_CONTAMINATED = NO +REPOSITORY_AFFECTED = NO +NONBLOCKING_FINDINGS = 1 +UNRESOLVED_MATERIAL_FINDINGS = 0 +P3_ARGV_RECORDING_FINDING_STATUS = OPEN_NON_BLOCKING +``` + +The external wrapper's recorded pytest argv does not preserve all non-path +arguments with complete fidelity: the recorded `no:cacheprovider` option value +is rendered as `/no:cacheprovider`. The fingerprinted guard passed the +correct literal argument to pytest. The fresh governed review independently +verified actual suite membership, test-level outcomes, exact skips, evidence +provenance, and candidate integrity, so this recording limitation does not +invalidate closure evidence. The finding is not fixed; neither wrapper nor sealed +evidence is edited by this reconciliation. + +Future DL-05 planning must decide whether this finding requires remediation +before reusing the affected wrapper for exact argv / command-attestation evidence. +That decision requires separate authorization; this task does not repair the +finding or start DL-05. + +### Documentation checks and remaining decision + +This reconciliation aligns the decision summary, exact identities, evidence +references, finding counts, completed acceptance, and next decision with README +and the automation plan. Documentation checks cover consistency, readability, +local links/references, authorized path scope, and whitespace. No application +test, report-index, closure validation, or governed review is repeated. + +DL-02 and DL-03 remain accepted without integration or release. DL-04 is accepted +for the exact closure candidate above, based on closure PASS, fresh governed +review PASS, and completed Owner acceptance ACCEPT / PASS. DL-05 remains +unstarted, and its integrated-implementation Safe CI has not run. At this +reconciliation, the next Owner decision was +`AUTHORIZE_DL_05_READINESS_AND_P3_IMPACT_REVIEW`. This documentation +grants no Lab1/Lab2 access, SSH/NETCONF/RESTCONF, provider/model operation, secrets +handling, configuration backup/change, production readiness, or complete-MVP +acceptance. No Stage-2 semantics, historical proof, or safety gate is changed. + +## DL-04 canonical closure contract + +**Retained specification: the separately authorized execution and review are +recorded above; its scope and requirements remain unchanged.** References below +to a future run or the specification task describe the original contract boundary. +DL-04 is the integrated offline validation, reviewer-evidence, +security-review, and closure layer over the accepted DL-01 through DL-03 behavior. +It adds no runtime capability. The completed readiness decision was +`DL_04_CANONICAL_SPECIFICATION_REQUIRED`: all 28 reviewed areas have predecessor +coverage, and no missing negative scenario or production-code defect was +demonstrated. This contract resolves the documentary scope gap without reopening +predecessor acceptance or creating tests merely to name a DL-04 file. + +The specification base is `d3bbe767fa5653bb7ee213594959877fd4ff80fe` on +`codex/dual-lab-vrrp-ai-query-mvp`. Accepted DL-02 and DL-03 implementation SHAs +remain the exact identities above. The resulting documentation commit is +identified externally by its actual SHA and parent; no self-referential commit +identifier is guessed inside its own contents. + +### Purpose, authority, and mutation boundary + +```text +DL_04_PURPOSE = INTEGRATED_OFFLINE_NEGATIVE_REGRESSION_VALIDATION_AND_REVIEWER_EVIDENCE_CLOSURE +DL_04_RUNTIME_CAPABILITY_ADDED = NO +DL_04_NEW_PRODUCTION_SOURCE_REQUIRED = NO +DL_04_EXISTING_PRODUCTION_SOURCE_MODIFICATION_REQUIRED = NO +DL_04_EXECUTION_AUTHORITY = NONE +DL_04_MODEL_PROVIDER_INTEGRATION = NO +DL_04_LIVE_AUTHORITY = NO +DL_04_CONFIG_MUTATION_AUTHORITY = NO +PRODUCTION_SOURCE_CHANGES = NONE +EXISTING_PRODUCTION_SOURCE_CHANGES = NONE +NEW_TEST_FILES = NONE +EXISTING_TEST_FILE_CHANGES = NONE +DEPENDENCY_CHANGES = NONE +WORKFLOW_CHANGES = NONE +EXISTING_ACCEPTED_COVERAGE_MUST_BE_REUSED = YES +DUPLICATIVE_TEST_CREATION_REQUIRED = NO +NO_MISSING_NEGATIVE_SCENARIO_CURRENTLY_DEMONSTRATED = YES +``` + +The current specification task may modify only this document and create its one +local documentation commit. A later closure-execution task may produce fresh +external validation/reviewer evidence and candidate provenance after separate +authorization. Its default repository mutation scope is empty, including this +document. Canonical status/documentation reconciliation needs a separately named +Owner authorization and exact paths; it is not implied by a passing run. + +No source, test, dependency, workflow, configuration, AGENTS.md, historical +Stage-2 record, Day1-Day160 artifact, dashboard, runner, or task registry change +is part of DL-04. No dedicated Stage-2 regression file is required. No live +Lab1/Lab2 access, SSH/NETCONF/RESTCONF, provider/model call, credential acquisition, +configuration backup/change, production execution, or DL-05 work is authorized. +If a genuine uncovered canonical requirement or defect is discovered, stop and +report it. Adding/fixing tests, source, or guard policy requires separate Owner +authorization; closure execution must not silently become a repair task. + +### Existing coverage and reviewer navigation + +DL-01 is the [inert aggregate contract](../../validation_framework/dual_lab_vrrp_query_contract.py). +DL-02 is the [fixed synchronous orchestrator](../../validation_framework/dual_lab_vrrp_query_orchestrator.py), +whose structural preflight and POLICY_2 behavior are described above. Pair-level +identity collisions reject both targets before invocation, even when a safely +parsed identity belongs to a locally ineligible target. With distinct identities, +a locally rejected target does not prevent the eligible peer's single mocked +handoff. DL-03 is the [facts-only projection](../../validation_framework/dual_lab_vrrp_query_summary.py): +deterministic sorting, exact comparisons, preserved multiplicity, failure absence, +and no health/root-cause/remediation inference. Data validity grants no authority. + +Every row below has classification +`EXISTING_BUT_REQUIRES_DL04_REVALIDATION`. This means fresh closure evidence is +required, not that accepted DL-01/02/03 behavior is being reopened. These are +coverage references, not a second safety matrix. Test symbols are resolved at +the candidate; all parameterized instances run through the complete suites. + +- **C**: [DL-01 tests](../../tests/dual_lab/test_dual_lab_vrrp_query_contract.py). +- **O**: [DL-02 tests](../../tests/dual_lab/test_dual_lab_vrrp_query_orchestrator.py). +- **S**: [DL-03 tests](../../tests/dual_lab/test_dual_lab_vrrp_query_summary.py). + +| ID | Existing area | Representative test symbols (all prefixed `test_`) | +| ---: | --- | --- | +| 1 | Canonical aggregate validation | C: `all_outcome_combinations_round_trip`, `strict_json_framing_types_limits_and_canonical_bytes`; O: `final_aggregate_failure_returns_no_partial_output` | +| 2 | Immutable/exact-field contracts | C: `every_aggregate_field_required`, `immutability_direct_construction_and_input_output_isolation`; S: `10_to_13_every_field_required_and_extras_reject`, `40_serializers_revalidate_tampering` | +| 3 | Duplicate JSON-key rejection | C: `duplicate_keys_rejected_at_every_depth`; S: `39_summary_duplicate_keys_at_each_depth`, `39_aggregate_duplicate_keys_at_each_depth` | +| 4 | Target binding | C: `aggregate_slot_target_binding`; O: `other_lab_request_binding_rejected`; S: `28_29_upstream_binding_and_identity_rejections` | +| 5 | Authorization ID/reference distinctness | O: `duplicate_identity_blocks_both_with_zero_invocations`, `p1_parseable_collision_overrides_local_rejection` | +| 6 | Malformed/expired authorization | O: `envelope_rejection_uses_existing_stage2_semantics`, `p1_distinct_identity_preserves_local_failure_and_policy2`, `p1_unparseable_identity_does_not_fabricate_collision` | +| 7 | Cross-target swaps | O: `invalid_bundle_only_skips_its_target`, `configuration_preflight_is_structural_and_target_local`, `envelope_rejection_uses_existing_stage2_semantics`; S: `summary_target_binding_and_tag_validation` | +| 8 | Missing runtime/startup binding | O: `both_preflights_finish_before_any_invocation`, `configuration_preflight_is_structural_and_target_local`, `two_local_failures_have_no_invocations` | +| 9 | No retry | O: `six_runtime_failures_preserved_independently_without_retry`, `exact_public_surface_no_executor_and_no_parallel_or_alternate_path` | +| 10 | No fallback | O: `invalid_bundle_only_skips_its_target`, `candidate_evidence_revalidated_without_substitution`, `exact_public_surface_no_executor_and_no_parallel_or_alternate_path` | +| 11 | No parallelism | O: `success_order_overlap_call_limit_and_canonical_roundtrip`, `exact_public_surface_no_executor_and_no_parallel_or_alternate_path` | +| 12 | No threads | O: `exact_public_surface_no_executor_and_no_parallel_or_alternate_path`; S: `35_36_42_static_public_surface_and_inert_import` | +| 13 | No async | C: `exact_public_surface_and_no_execution_capabilities`; O: `exact_public_surface_no_executor_and_no_parallel_or_alternate_path`; S: `35_36_42_static_public_surface_and_inert_import` | +| 14 | No queue | Same C/O/S restricted-surface tests as row 13, with source inspection | +| 15 | No scheduler | Same C/O/S restricted-surface tests as row 13, with source inspection | +| 16 | No worker | Same C/O/S restricted-surface tests as row 13, with source inspection | +| 17 | Canonical evidence revalidation | C: `direct_success_detaches_evidence_and_revalidates_forged_records`, `serialization_rechecks_objects_altered_after_construction`; O: `candidate_evidence_revalidated_without_substitution` | +| 18 | Raw-output leakage rejection | C: `prohibited_data_cannot_enter_evidence_or_record`; O: `six_runtime_failures_preserved_independently_without_retry`; S: `13_envelope_exclusion_and_exact_fields`, `public_errors_discard_outer_context_and_upstream_failure` | +| 19 | Credential/signature/path leakage rejection | C: `unknown_and_prohibited_top_level_fields_rejected`; O: `bundle_immutable_redacted_exact_fields_and_no_leakage`; S: `10_to_13_every_field_required_and_extras_reject`, `27_extra_aggregate_fields_rejected_upstream` | +| 20 | Deterministic DL-03 projection | S: `07_to_09_multirecord_sort_permutations_metadata_and_identity`, `39_derived_rows_recomputed_and_exactly_typed` | +| 21 | Multi-record projection | S: `07_to_09_multirecord_sort_permutations_metadata_and_identity`, `41_maximum_records_name_lengths_and_serialization_bounds` | +| 22 | Duplicate matching-key handling | S: `24_duplicate_multiplicity_never_pairs_or_collapses`, `24_complete_duplicate_sort_tie_breaker` | +| 23 | Missing-evidence behavior | S: `01_to_04_outcomes_and_failure_absence`, `11_failure_cannot_have_records`, `25_38_failure_vocabulary_and_no_fabrication` | +| 24 | Prohibited health inference | S: `30_to_34_prohibited_structures_and_opaque_observed_names`; closed observation vocabulary above | +| 25 | Prohibited root-cause inference | S: `30_to_34_prohibited_structures_and_opaque_observed_names` (`ROOT_CAUSE`) | +| 26 | Prohibited remediation output | S: `30_to_34_prohibited_structures_and_opaque_observed_names`, `33_command_like_name_remains_only_data` | +| 27 | No provider/model integration | C: `exact_public_surface_and_no_execution_capabilities`; O: `exact_public_surface_no_executor_and_no_parallel_or_alternate_path`; S: `35_36_42_static_public_surface_and_inert_import` | +| 28 | No execution authority in DL-03 | S: `01_to_04_outcomes_and_failure_absence`, `35_36_42_static_public_surface_and_inert_import`, `40_serializers_revalidate_tampering` | + +Restricted imports/public surfaces and source inspection support the absence of +threads/queue/scheduler/worker; do not describe them as separate dynamic tests +for every mechanism. Leakage checks cover schema exclusion, envelope removal, +sanitized errors/representations, and output silence. Opaque observed names are +not a general secret-detection mechanism. No new end-to-end composition test is +mandated by this contract; any later proposed coverage addition needs its own +demonstrated gap and authorization. + +### Candidate identity and deterministic offline command map + +Before a future run, the Owner must name the exact candidate SHA **C**, its parent +**P**, the comparison base **B**, and the specification commit **S** containing +this contract. Record all four as full resolved SHAs in `candidate-manifest.json`. +The specification's base above is fixed; do not assume that it is also a future +candidate's parent. No floating HEAD or branch-only identity suffices. Require +the clean authorized branch, the accepted predecessor identities, and unchanged +accepted source/test bytes before starting. A moved candidate or ambiguous base +blocks the run; do not move history to repair it. +Require C to equal S or be an explicitly authorized documentation-only descendant +of S. Compare the complete production-source/test file sets and bytes with S; +no source/test delta is allowed. Record the comparison with B separately rather +than treating an arbitrary comparison base as permission for new behavior. + +Use a fresh external disposable checkout/copy of C, preserving exact tracked raw +bytes and required local fixture Git behavior. The primary checkout remains +unchanged. Verify any preinstalled interpreter/dependency assets without +installation, network fetch, or dependency changes. Record versions, executable +fingerprints, import-root identities, platform, non-TTY mode, guard and launcher +hashes, and the resolved command map externally. Never capture environment dumps, +credential stores, live startup bindings, or private runtime configuration. + +All commands below are **future logical invocations inside the guarded external +launcher**, not permission to run unguarded CLI commands. The deterministic +procedure is to select one row, install the accepted guard before importing +pytest/application modules, then call `pytest.main` with this exact common argv: + +```text +[-p, no:cacheprovider, --color=no, -q, --tb=short, -rs, + --basetemp, /pytest, ] +``` + +| Logical mode | Canonical targets / logical command | Required artifact | +| --- | --- | --- | +| `focused-dl01` | `python -m pytest tests/dual_lab/test_dual_lab_vrrp_query_contract.py` | `focused-dl01.json` | +| `focused-dl02` | `python -m pytest tests/dual_lab/test_dual_lab_vrrp_query_orchestrator.py` | `focused-dl02.json` | +| `focused-dl03` | `python -m pytest tests/dual_lab/test_dual_lab_vrrp_query_summary.py` | `focused-dl03.json` | +| `dual-lab` | `python -m pytest tests/dual_lab` | `dual-lab.json` | +| `stage2` | `python -m pytest tests/stage2` | `stage2.json` | +| `full` | `python -m pytest` (no target restriction) | `full-pytest.json` | +| `report` | `python network_lab.py --task report-index` | `report-index.log` and `report-index.json` | + +Run rows serially, with fresh external temporary storage per mode. Disable plugin +autoload, pytest cache and bytecode writes; clear injected pytest options/plugins. +Do not use selectors, deselection, fail-open mocking, response files, or extra +skips to reduce required coverage. Preserve collection counts, every test outcome, +exit status, exact skip reasons, guard observations and sanitized logs. A test +failure, collection error, missing proof, or guard discrepancy stops promotion; +retain failed/setup-attempt evidence instead of overwriting it with a retry. + +The accepted guard is the external `frozen_v3_guard.py`, SHA256 +`0c0357e7291efb90b9c0a21aec212ddb183cc8778182060020e087ea60d4a5bb`. +Resolve it through retained evidence, never by an unverified same-name file. +The external launcher may relocate approved disposable/evidence/tool paths and +map the modes above, and add result collection, without changing frozen guard +bytes or denial/skip semantics. Review and fingerprint that launcher before +execution. If the guard or required baseline cannot be recovered, or relocation +needs a guard-policy change, stop for Owner resolution; do not improvise a weaker +guard or install dependencies. This task creates or executes no launcher. + +Preserve guard coverage through collection, execution, teardown, ordinary Python +children, Windows spawn, and permitted Node subprocesses. Deny real sockets/DNS, +native credential/trust acquisition, provider calls, uncontrolled subprocesses, +private configuration, and live device I/O. The existing narrow synthetic-memory, +disposable SQLite, fixture-Git, guarded child-process, and symlink test allowances +may be reused only unchanged. They prove existing offline regressions, not new +production threads/workers/parallel execution. The guard is a trusted regression +harness, not a sandbox for arbitrary hostile code. Do not broaden its guarantees. + +The six following proofs must each report call-phase `passed` in the complete +fresh full-suite result. Extract their node IDs/outcomes into +`workflow-proofs.json`; a count, collection-only result, xfail, or skip is not PASS: + +```text +tests/stage2/test_authorization_envelope_ledger.py::test_competing_processes_and_process_restart +tests/workflow_governance/test_validate_fast.py::test_cli_plan_rejects_response_file_path_with_deterministic_json +tests/workflow_governance/test_validate_fast.py::test_symlink_escape_changed_path_is_rejected_when_supported +tests/workflow_governance/test_validate_scope.py::test_option_like_revision_cli_emits_json_and_exit_two[base] +tests/workflow_governance/test_validate_scope.py::test_option_like_revision_cli_emits_json_and_exit_two[head] +tests/workflow_governance/test_validate_scope.py::test_symlink_escape_is_rejected_when_supported +``` + +These are preservation checks for the accepted workflow/replay behavior. They +neither reopen deferred workflow research nor authorize running Safe CI. + +```text +HISTORICAL_RESULTS_COUNT_AS_FRESH_DL04_EVIDENCE = NO +FRESH_DL04_VALIDATION_REQUIRED = YES +``` + +### Exact established skips and guard-denial baseline + +The identity set below comes from the retained accepted DL-03 review evidence +summarized above, inspected during this specification task without rerunning it. +Logical baseline artifact fingerprints bind that source without publishing its +private retention path: + +| Historical artifact | Raw SHA256 | +| --- | --- | +| `verification.json` | `1b982be6d356c1ec2af3548b26123563127c3205bcc46854aa6d1adcdf73eba6` | +| `stage2.json` | `69b273aa9f2aee28f05791e81b3a96bd8bb50d24a1d29fb19415f16e2ae063d9` | +| `full.json` | `52cc9c288bfc0401e8497db352aced56703a32ecb2a980371fe987f8cfb1b1d0` | + +Historical artifacts are read-only comparison inputs, not files to copy into the +fresh result slots. Their retrieval location is supplied locally outside the +repository. Missing artifacts or fingerprint mismatch blocks exact baseline +comparison. The 211/652/1890/4669 passes, established 2/3 skips, 66 external probes, +three denials and 13 optional missing reports remain historical results only. +DL-04 does not require duplicating the old external probe suite or its test count; +it requires the mapped current tests and fresh security review below. + +| Accepted skip node ID | Exact reason | Applicable suite/environment | +| --- | --- | --- | +| `tests/stage2/test_live_authorization_owner_trust_root.py::test_disposable_regular_file_is_accepted_by_exact_native_path` | `Accepted Stage-2 offline safety exclusion: real Win32 trust-root source operations are forbidden.` | Stage-2 and full, accepted Windows offline guard | +| `tests/stage2/test_live_authorization_owner_trust_root.py::test_confirmed_trailing_dot_alias_is_rejected_as_noncanonical` | `Accepted Stage-2 offline safety exclusion: real Win32 trust-root source operations are forbidden.` | Stage-2 and full, accepted Windows offline guard | +| `tests/test_phase_2n_02_canonical_flask_demo_smoke.py::test_canonical_flask_process_lifecycle_and_get_only_routes` | `Accepted full-suite offline safety exclusion: Flask server/socket lifecycle and native process inspection are forbidden.` | Full only, accepted Windows offline guard | + +All three predate C and are classified `ACCEPTED_PRE_EXISTING` only after baseline +and candidate source/environment checks. `skip-classification.json` records each +full node ID, exact observed reason, applicability, baseline evidence reference, +predates-candidate proof and disposition. The three focused suites and complete +Dual-Lab permit no skips. Stage-2 must match exactly its two rows, and full exactly +all three; counts alone do not establish a match. Changed environments do not +silently inherit these exclusions. An unexpected, missing, renamed or unexplained +skip blocks promotion and requires classification/Owner resolution; no additional +skip may be approved during closure execution. + +```text +UNEXPECTED_SKIP_COUNT = 0 +NEW_OR_UNEXPLAINED_SKIP_RESULT = DL_04_CLOSURE_RESULT: FAIL_OR_BLOCKED +``` + +The known full-suite denial set is exactly the following three call-phase events, +one occurrence each. The table lists the ordered repository frames from the +denied operation outward; the retained full artifact supplies the guard frames. + +| Denial node ID | Exact ordered repository frame signature (`file:function:line`) | +| --- | --- | +| `tests/test_day13_multi_router_wireguard_validation.py::test_multi_device_live_validation_reminds_before_next_router` | `mikrotik_day2_auto_setup.py:connect_ssh_keyboard_interactive:311` → `mikrotik_day2_auto_setup.py:connect_ssh:338` → `mikrotik_day2_auto_setup.py:connect_ssh_with_auth_retry:370` → `mikrotik_day13_multi_router_wireguard_validation.py:run_router_lan_host_ping:911` → `mikrotik_day13_multi_router_wireguard_validation.py:run_day12_for_devices:950` → `mikrotik_day13_multi_router_wireguard_validation.py:main:1323` → `tests/test_day13_multi_router_wireguard_validation.py:test_multi_device_live_validation_reminds_before_next_router:628` | +| `tests/test_day8_iperf3_command_builder.py::test_default_args_use_40_second_duration_and_10_second_omit` | `performance_test.py:infer_wan_client_ip:126` → `performance_test.py:build_config_from_args:259` → `tests/test_day8_iperf3_command_builder.py:test_default_args_use_40_second_duration_and_10_second_omit:114` | +| `tests/workflow_governance/test_validate_scope.py::test_invalid_or_missing_revision_is_structured_error[missing-revision]` | `scripts/validate_scope.py:run_read_only_git:110` → `scripts/validate_scope.py:resolve_commit:193` → `scripts/validate_scope.py:validate_scope:321` → `tests/workflow_governance/test_validate_scope.py:test_invalid_or_missing_revision_is_structured_error:257` | + +These are blocked attempts inside historical tests; their names do not grant +live permission. In particular, historical Day1-Day160 retry-oriented function +names do not describe DL-02 retry behavior and must not be rewritten here. +The operation must remain denied before the forbidden side effect. A passing +pytest assertion alone cannot excuse a different denial or an actual side effect. + +The corresponding raw source/test fingerprints, verified equal to the retained +review workspace at specification time, are: + +| Repository-relative file | SHA256 | +| --- | --- | +| `mikrotik_day2_auto_setup.py` | `707e60fd71e414f063f438d61dba1acff01e26a68c24d600a4d1c8cc7c1e75ee` | +| `mikrotik_day13_multi_router_wireguard_validation.py` | `97944fbebfad7a45ce597decf72a1c15555eed68d1474a0cb0a7e6a622022e74` | +| `tests/test_day13_multi_router_wireguard_validation.py` | `4711b59b14894ecd966ddccfcb3d1e02ef7433b08f4cbbff702fb87301e73ccd` | +| `performance_test.py` | `b0f28cf4555cc0b4519d4d7949bd690cfda8e49c29dce1cf1778b39ee6f88a60` | +| `tests/test_day8_iperf3_command_builder.py` | `e10cc5c3753a2e45bfd6728e7ca3e3d583161b40270419f05ee05badca82871a` | +| `scripts/validate_scope.py` | `e7a90e7e543439dfa2c7da9e51e9438ec23fc29ca064a2116cbfefd2be49f3e2` | +| `tests/workflow_governance/test_validate_scope.py` | `0e4e94c89f44aa45ed0e11f5f70b8b1f196cce5cdd52db375cdf6562184abd11` | + +`guard-classification.json` must compare node ID, call phase, occurrence count, +guard fingerprint, ordered source frames and raw source bytes with this baseline. +Normalize only path separators and disposable-root prefixes; do not discard +function names, line numbers, test parameters, or causality. Record sanitized +event identities and process/accounting scope. The historical counter observes +the top-level parent; do not claim it counts every child. Preserve guard +installation in children and the required child-process proof outcomes. Any +observed child denial/error must be classified and cannot be hidden by parent +counts. Fresh focused/Dual-Lab/Stage-2/report modes require zero denials; full +requires exactly these three. Any new/unclassified or DL-04-caused denial blocks +closure, even inside a known test. Guard weakening to obtain zero denials fails. + +```text +KNOWN_PRE_EXISTING_GUARD_DENIAL_COUNT = 3 +KNOWN_PRE_EXISTING_GUARD_DENIAL_SET_MATCH = YES +NEW_OR_UNCLASSIFIED_GUARD_DENIAL_COUNT = 0 +DL04_CAUSED_GUARD_DENIAL_COUNT = 0 +``` + +### Stage-2 preservation and candidate-bound security review + +```text +S2_RO_01_THROUGH_S2_RO_11_SEMANTICS_CHANGED = NO +STAGE2_CHANGE_REQUIRED = NO +STAGE2_REOPEN_REQUIRED = NO +DEDICATED_DL04_STAGE2_REGRESSION_FILE_REQUIRED = NO +``` + +Use the complete existing Stage-2 suite and inspect the accepted boundaries for +Owner authorization, replay consumption, credential binding, pinned-host +verification, exact read-only command policy and one-shot handoff. Relevant +existing suites include `test_owner_verifier.py`, +`test_authorization_envelope_ledger.py`, `test_mikrotik_credential_resolver.py`, +`test_windows_credential_backend.py`, `test_known_host_snapshot.py`, +`test_vrrp_readonly_command_policy.py`, `test_pinned_ssh_transport.py`, +`test_trusted_runtime_composition.py` and `test_vrrp_readonly_live_entrypoint.py`, +all under `tests/stage2`. Combine them with the mapped DL-02 collision, +zero-invocation and evidence-revalidation cases. Mock/synthetic coverage is not +fresh live proof and does not resolve historical Stage-2 closure or replay +provenance limits. + +A fresh review of C against B must record reviewer/process provenance, exact +reviewed paths, assertion-to-source/test/result references, limitations, findings +and dispositions in `security-review.json`. A same-conversation review must be +labeled as such; do not invent a separate reviewer identity. Existing static +surface checks support, but do not replace, fresh inspection. Historical security +PASS cannot substitute for this review. The following are mandatory PASS +assertions about the Dual-Lab product boundary and authorized offline run, not +claims that the entire historical repository contains no process facilities: + +```text +NO_ARBITRARY_EXECUTOR = YES +NO_RETRY = YES +NO_FALLBACK = YES +NO_PARALLEL_EXECUTION = YES +NO_THREADS = YES +NO_ASYNC = YES +NO_QUEUE = YES +NO_SCHEDULER = YES +NO_WORKER = YES +NO_PROVIDER_MODEL_CALLS = YES +NO_SECRET_LEAKAGE = YES +NO_RAW_STDOUT_LEAKAGE = YES +NO_CONFIG_MUTATION = YES +NO_LIVE_AUTHORITY_EXPANSION = YES +NO_PROHIBITED_INFERENCE = YES +NO_HISTORICAL_EVIDENCE_SUBSTITUTION = YES +PAIR_IDENTITY_COLLISION_PRECEDENCE = PASS +REJECTED_TARGET_ZERO_INVOCATION = PASS +CANONICAL_EVIDENCE_BINDING_REVALIDATION = PASS +IMMUTABLE_CLOSED_SCHEMAS = PASS +SANITIZED_FAILURE_BOUNDARY = PASS +STAGE2_AUTHORITY_BOUNDARIES_UNCHANGED = PASS +``` + +No arbitrary executor argument, alternate handoff, guessed duplicate pairing, +health/root-cause/remediation verdict, or weakening of fixed errors/schemas may +appear. Rejected targets must have zero handoff where the existing contract +requires it; a valid peer's POLICY_2 handoff remains bounded and mocked. A +material security finding fails the candidate gate; missing proof blocks it. +Neither permits an in-task repair or reopening accepted slices automatically. + +### Report-index, external artifacts, and provenance + +Fresh `report-index` must exit successfully and return PASS, or WARN only with +zero failures, zero mandatory missing, zero unknown, and every missing artifact +documented as optional under the existing registry. Thirteen optional missing +reports is the historical baseline, not a fixed expected total. Classify any +change and retain item-level evidence; do not relabel a new mandatory missing +artifact as optional to obtain WARN. No registry or report-rendering changes are +authorized. + +Use one fresh non-repository evidence root with deterministic logical names +below. Physical locations remain external/private. JSON artifacts contain the +common metadata fields `candidate_sha`, `candidate_parent_sha`, `comparison_base_sha`, +`specification_sha`, `procedure`, `scope`, `result`, and `evidence_kind` (`FRESH` +or `HISTORICAL_BASELINE`). Include run identity and UTC start/end timestamps for +fresh run provenance; time alone is never proof of freshness. Commands use +logical root/tool aliases with fingerprinted resolutions, not personal paths or +environment values. Each result must reference the actual invocation and exact +input/source identities. Log metadata belongs in its companion JSON. + +| Required logical artifact | Required contents / generation procedure | +| --- | --- | +| `candidate-manifest.json` | C/P/B/S, branch, accepted predecessor SHAs, platform/tool/guard/launcher fingerprints, frozen command map and baseline references; prepared before execution, finalized with artifact index | +| `focused-dl01.json` | Fresh mapped DL-01 suite invocation, collection/outcomes, exit status, skips and guard results | +| `focused-dl02.json` | Same for DL-02 | +| `focused-dl03.json` | Same for DL-03 | +| `dual-lab.json` | Complete fresh Dual-Lab suite results | +| `stage2.json` | Complete fresh guarded Stage-2 results and exact established skips | +| `full-pytest.json` | Complete fresh guarded full-suite results, node outcomes, exact skips and denial references | +| `workflow-proofs.json` | Extracted six required call-phase outcomes and references into `full-pytest.json` | +| `report-index.log` | Sanitized fresh report-index output | +| `report-index.json` | Common metadata, log hash, exit status, item classifications, failure/mandatory-missing/unknown counts and WARN rationale | +| `skip-classification.json` | Exact observed/expected identity sets, reasons, applicability, predecessor proof and unexpected-count decision | +| `guard-classification.json` | Exact baseline matching, event/source fingerprints, process scope, denied-before-side-effect proof, new/unclassified and candidate-causality decisions | +| `security-review.json` | Fresh candidate/base-bound review of all assertions and six preservation proofs; findings/dispositions and reviewer/process limitations | +| `tracked-integrity.json` | Before/after primary and disposable tracked-file sets/raw hashes, C/P/B identity checks, status results and mutation classification | +| `reviewer-readability.json` | Navigation/coverage mapping, readability assertions, exact reviewed document hash and evidence references | +| `dl04-closure-result.json` | Each completion predicate, findings, state reached, evidence references, `DL_04_ACCEPTED = NO` until a separate acceptance receipt | +| `governed-review.json` | Later fresh governed review of the frozen closure candidate; cannot be generated as completed evidence by the earlier execution gate | +| `owner-acceptance.json` | Later explicit Owner decision tied to C and the reviewed evidence manifest; absent/pending until that separate decision | + +The manifest indexes every completed artifact by logical name, byte size, +SHA256, procedure, scope and freshness. It does not hash itself recursively: +freeze and hash the finalized manifest externally, and have later review/Owner +receipts reference that hash. Later receipts are append-only artifacts, each +with its own metadata, not edits that silently change the reviewed manifest. +The pre-run manifest snapshot and all failed attempts remain identifiable. +No artifact may contain credentials, private keys, signatures, raw device output, +raw secret-like environment values, or private live runtime details. Baseline +artifacts with local operational paths remain private comparison inputs; publish +only sanitized identities/fingerprints and logical references in new evidence. + +```text +DL_04_EVIDENCE_PROVENANCE_REQUIRED = YES +HISTORICAL_EVIDENCE_MAY_IMPERSONATE_FRESH_EVIDENCE = NO +``` + +### Candidate integrity and reviewer readability + +Before validation, enumerate the candidate's exact tracked-file set and record +raw-byte SHA256 for every file in both primary and disposable copies, together +with commit/tree identities and clean status. After all runs/reviews, compare +the same complete sets and bytes, especially source/tests, and recheck candidate +SHA and clean primary worktree. Git content normalization alone is insufficient +for raw-byte equality. No unexpected additions, deletions, index/ref changes, or +tracked mutation is acceptable. Evidence stays external. + +The frozen harness may create only its already permitted disposable temporary +fixtures and untracked generated report outputs in the disposable copy. Classify +them explicitly; they are not permission to alter tracked reports or publish +runtime artifacts. A tracked-content change yields `DL_04_CLOSURE_RESULT = FAIL`. +This contract grants no restoration exception: stop and preserve evidence rather +than cleaning away the discrepancy. A future exception would require an existing +explicit canonical rule and exact authorized restoration proof, not an ad hoc +rule invented after the run. Final candidate/worktree identity must be clean. + +The reviewer uses this single navigation layer, its linked contracts/tests, +command map, exact skip/guard tables, and the external manifest. Record C/P/B/S, +artifact provenance, report interpretation, candidate-bound security result, +integrity proof, closure state and incomplete-MVP status together. Do not add +another safety matrix or duplicate historical Stage-2 documentation. Missing +readability evidence blocks completion; readability PASS never overrides a +technical/security failure. Require these assertions in `reviewer-readability.json`: + +```text +REVIEWER_NAVIGATION_CLEAR = YES +COMMAND_MAP_COMPLETE = YES +REQUIREMENT_TO_EVIDENCE_MAPPING_CLEAR = YES +SKIP_EXPLANATIONS_CLEAR = YES +GUARD_EXPLANATIONS_CLEAR = YES +SECURITY_BOUNDARIES_CLEAR = YES +NO_LIVE_AUTHORITY_CLAIM_CLEAR = YES +NO_PROVIDER_MODEL_AUTHORITY_CLAIM_CLEAR = YES +COMPLETE_MVP_STATUS_CLEAR = YES +``` + +### Closure states, checkpoints, and completion gate + +States advance only on the named evidence/authorization. A later gate cannot +retroactively satisfy an earlier one. FAIL means a demonstrated failed predicate; +BLOCKED means missing/unverifiable evidence, environment or authorization. Both +stop promotion, leave acceptance NO, and require an exact reason. No automatic +repair, retry, acceptance or documentation reconciliation follows. + +| Ordered state | Required transition checkpoint | +| --- | --- | +| `DL_04_SPECIFICATION_ESTABLISHED` | This bounded document is reviewed and committed; no execution or acceptance | +| `DL_04_CLOSURE_EXECUTION_AUTHORIZED` | Separate Owner authorization names C/P/B/S, external evidence boundary and unchanged mutation exclusions; clean status and pre-run integrity verified | +| `DL_04_FRESH_VALIDATION_PASS` | All seven command-map runs pass their policies; six workflow/replay proofs, skip/guard classification and integrity pass | +| `DL_04_REVIEWER_EVIDENCE_COMPLETE` | Validation, classification, integrity, requirement mapping, provenance and readability artifacts complete; security review and closure-result artifacts remain pending at their distinct checkpoints | +| `DL_04_SECURITY_REVIEW_PASS` | Fresh review of the same C/B and frozen validation inputs satisfies all security assertions with zero unresolved material findings | +| `DL_04_CLOSURE_CANDIDATE` | All predicates below pass, C unchanged and worktree clean; record candidate result with acceptance NO, then finalize and freeze the artifact manifest | +| `DL_04_FRESH_GOVERNED_REVIEW` | Separately authorized fresh review verifies C, frozen manifest hash, complete evidence, findings and integrity; record actual process/identity without claiming unperformed independence | +| `DL_04_OWNER_ACCEPTANCE` | Separate Owner ACCEPT/REJECT decision references exact C and governed-review receipt; a review PASS is not that decision | +| `DL_04_ACCEPTED` | Only after Owner ACCEPT with passing review and unchanged evidence identity; repository status reconciliation still requires separate exact documentation authority | + +If canonical candidate documentation is requested between candidate creation and +governed review, that is a separate documentation-only authorization and commit +**D**. Record D and its parent alongside the unchanged validated C and frozen +manifest. Verify its exact permitted documentation diff; it must not masquerade +as the validated code commit. Any non-documentation candidate change invalidates +freshness and requires new scope/validation authority. The same distinction +applies to post-acceptance reconciliation: it records a decision, not new tests, +runtime acceptance or integration. No documentation write is implicitly granted +by this state machine. + +A closure candidate requires **every** predicate below, supported by fresh +candidate-bound artifacts; the listed values are requirements, not current results: + +```text +DL01_FOCUSED = PASS +DL02_FOCUSED = PASS +DL03_FOCUSED = PASS +DUAL_LAB = PASS +STAGE2 = PASS_WITH_ONLY_ESTABLISHED_SKIPS +FULL_PYTEST = PASS_WITH_ONLY_ESTABLISHED_SKIPS +WORKFLOW_GOVERNANCE_PROOFS = PASS +REPORT_INDEX = ACCEPTABLE +UNEXPECTED_SKIP_COUNT = 0 +KNOWN_GUARD_SET_MATCH = YES +NEW_GUARD_DENIAL_COUNT = 0 +DL04_CAUSED_GUARD_DENIAL_COUNT = 0 +SECURITY_REVIEW = PASS +EVIDENCE_PROVENANCE = PASS +TRACKED_INTEGRITY = PASS +REVIEWER_DOCUMENTATION = COMPLETE +READABILITY_REVIEW = PASS +UNRESOLVED_MATERIAL_FINDINGS = 0 +DL_04_ACCEPTED = NO +``` + +Even this candidate does not establish acceptance until separate fresh governed +review and Owner acceptance. DL-04 acceptance is local slice acceptance only. +DL-05 retains independent acceptance plus Safe CI at the integrated implementation +commit. Do not claim `SAFE_CI = COMPLETE`, +`FINAL_INTEGRATED_MVP_ACCEPTANCE = COMPLETE`, or +`LIVE_DUAL_LAB_AUTHORIZED = YES` from any DL-04 state. + +```text +DL_04_SAFE_CI_REQUIRED = NO +DL_05_SAFE_CI_BOUNDARY_PRESERVED = YES +``` + +### Historical specification result and next Owner decision + +At specification establishment, before the later execution and review recorded +above, the following status and next decision applied. This historical block is +not the current DL-04 status. + +This specification's documentation consistency/readability review checks all 28 +coverage references, the command/proof map, exact baseline identities, external +artifact contract, security predicates and distinct state transitions. Its +conclusion, allowed writes, forbidden execution, acceptance limits and next Owner +decision are explicit. Accepted predecessor statuses and historical Stage-2 +semantics remain unchanged. No runtime pytest, report-index, closure execution, +new security-validation result, or Safe CI is performed by this task. + +```text +DL_02_ACCEPTED = YES +DL_03_ACCEPTED = YES +DL_04_SPECIFICATION_STATUS = ESTABLISHED +DL_04_IMPLEMENTATION_STARTED = NO +DL_04_CLOSURE_EXECUTION_STARTED = NO +DL_04_ACCEPTED = NO +DL_05_STARTED = NO +COMPLETE_DUAL_LAB_MVP = NO +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +NEXT_REQUIRED_OWNER_DECISION = AUTHORIZE_BOUNDED_DL_04_CLOSURE_EXECUTION +``` + + +## DL-05 canonical integration and Safe CI contract + +**Decision summary: the DL-05 specification is established. Execution, remote +mutation, Safe CI, and acceptance remain unstarted.** DL-05 will integrate the +accepted Dual-Lab content into authoritative `main`, require both pre-merge and +post-merge Safe CI, and obtain fresh exact-SHA governed review before a separate +Owner acceptance decision. The P3 argv-recording finding remains open and +non-blocking because DL-05 does not use the affected wrapper for command evidence. + +### Specification basis and permitted scope + +The specification base is +`d3216f774d28e84b828355a727bc9807f1ce3b46` on +`codex/dual-lab-vrrp-ai-query-mvp`. The completed +`DL_05_READINESS_AND_P3_IMPACT_REVIEW_ONLY` response in the same conversation +returned `DL_05_CANONICAL_SPECIFICATION_REQUIRED`. The subsequent Owner +specification authorization resolves its identified gaps through this contract; +it does not convert the prior review into an execution or acceptance result. + +At readiness review, the feature branch and PR were absent remotely; the current +canonical HEAD and accepted DL-02/03/04 SHAs were local only. Authoritative remote +`main` was `d57b802e5d7a9cbe1d684c3dd058527685c56d62`. These are retained +readiness observations, not fresh remote-state checks by this specification task. +Future remote work must verify current remote identity and state again. + +| Retained accepted identity | Exact value | +| --- | --- | +| DL-02 implementation | `bb39e8295a2fa5d69980396dbaf8c374c79a57b3` | +| DL-03 implementation | `e4c529507c6f5a26a289af4f6d3bfc9e0b170c18` | +| DL-04 closure candidate | `0972910c4d5e2bb7d70acbb50c4721234f20d0dc` | +| DL-04 accepted manifest SHA256 | `f7dd14e1ea471032cf68e108d2ea85b144096d28f3aa6c339efd89bf27567449` | + +```text +DL_05_PURPOSE = INTEGRATE_ACCEPTED_DUAL_LAB_MVP_TO_AUTHORITATIVE_MAIN_AND_PROVE_EXACT_MAIN_SHA_WITH_SAFE_CI_AND_FRESH_EXACT_SHA_ACCEPTANCE +DL_05_NEW_PRODUCTION_BEHAVIOR = NO +DL_05_SOURCE_FEATURE_IMPLEMENTATION = NO +DL_05_TEST_FEATURE_IMPLEMENTATION = NO +DL_05_LIVE_AUTHORITY = NO +DL_05_CONFIG_MUTATION_AUTHORITY = NO +DL_05_MODEL_PROVIDER_INTEGRATION = NO +``` + +The current task may edit only this canonical MVP record and create its one local +specification commit. No README, automation-plan, Stage-2 closure, source, test, +workflow, dependency, configuration, wrapper, or sealed-evidence change is +permitted. No application test, report-index, push, PR, merge, Safe CI, P3 repair, +Owner acceptance, or live operation belongs to this task. No accepted DL-02, +DL-03, or DL-04 decision is reopened. No new runtime feature, runner, adapter, +queue, scheduler, worker, AI loop, arbitrary executor, Day1-Day160 rewrite, or +second safety matrix is introduced. + +The unchanged README and automation plan retain the earlier readiness next-step +summary. Their accepted-predecessor, unstarted-DL-05, incomplete-MVP, and no-live +statuses remain compatible. This canonical section supplies the newly authorized +DL-05 specification and next decision; those summaries receive no implicit edit +authority. Historical Stage-2 semantics and its distinct closure gates remain +unchanged. + +### Integrated commit and candidate identity + +Before integration, the exact approved feature/PR HEAD is +`DL_05_PREMERGE_CANDIDATE_SHA`. It contains the complete accepted Dual-Lab branch +content and approved canonical documentation state, including this specification. +It is not the final integrated implementation commit merely because it contains +accepted history. A later Owner authorization must name that exact candidate, +source branch, trusted repository, target `main`, and permitted remote actions. + +Pre-merge provenance distinguishes three immutable commit identities: + +| Identity | Meaning | +| --- | --- | +| `DL_05_PREMERGE_PR_HEAD_SHA` | Exact head commit of the feature branch submitted by the PR; the approved `DL_05_PREMERGE_CANDIDATE_SHA` must equal this SHA. | +| `DL_05_PREMERGE_BASE_SHA` | Authoritative `main` commit forming the PR integration base at the validated PR state. | +| `DL_05_PREMERGE_TEST_SHA` | Synthetic PR merge commit represented by `refs/pull//merge` and actually checked out/tested by the unchanged Safe CI `pull_request` workflow. | + +The workflow's pinned `actions/checkout` has no explicit `ref`. Its default +`pull_request` checkout tests the PR merged result. PR head and tested merge SHA +are separate provenance facts; their difference is expected, and equality must +not be required. Do not invent a future synthetic merge SHA before the PR exists. + +```text +PREMERGE_CHECKOUT_EXPLICIT_PR_HEAD_REF = NO +PREMERGE_DEFAULT_PULL_REQUEST_MERGE_REF_BEHAVIOR = YES +PREMERGE_PR_HEAD_SHA_IS_SAFE_CI_CHECKOUT_SHA = NO +PREMERGE_SAFE_CI_TESTS_MERGED_RESULT = YES +``` + +After separately authorized integration, capture the resulting authoritative +`main` SHA as `DL_05_INTEGRATED_IMPLEMENTATION_COMMIT`. This is the final DL-05 +acceptance target. Resolve every candidate, parent/base, workflow, and final main +identity to a full immutable SHA; branch names alone are insufficient. + +```text +DL_05_INTEGRATED_IMPLEMENTATION_COMMIT_BEFORE_MERGE = NOT_YET_CREATED +INTEGRATED_COMMIT_MUST_BE_ON_AUTHORITATIVE_MAIN = YES +CURRENT_LOCAL_HEAD_IS_FINAL_INTEGRATED_COMMIT = NO +FINAL_DL05_ACCEPTANCE_TARGET = POSTMERGE_AUTHORITATIVE_MAIN_SHA +``` + +The merge strategy remains governed by repository integration policy and the +separate merge authorization. This specification selects no merge method. Do not +require accepted feature commits to become first-parent main commits. Regardless +of strategy, compare the resulting main tree/content with the approved integration +candidate: the accepted source/tests and approved documentation must be present, +the complete diff must be understood, and no unauthorized material change may +have entered. Record transformed commit relationships, the exact result SHA, +content comparison, and clean repository state. Unexpected behavior changes give +`DL_05_RESULT = FAIL_OR_BLOCKED`; do not repair or rewrite history in that gate. + +### Pre-merge and post-merge CI gates + +The future pre-merge operation requires separate Owner authorization to push the +exact branch and create its PR targeting authoritative `main`. Record PR number, +URL, head ref/SHA, base ref/SHA, merge ref, and exact tested merge SHA separately. +Let the existing PR-triggered Safe CI run on that PR's merged-result ref, capture +its receipt, and verify all required jobs/steps and head/base/test correspondence +before a separate read-only pre-merge CI/integration review. Only after that +review passes may the Owner separately authorize merge. A branch push alone +does not trigger the required Safe CI. + +```text +REMOTE_BRANCH_REQUIRED = YES +PR_TARGET = main +PREMERGE_SAFE_CI_REQUIRED = YES +PREMERGE_SAFE_CI_TRIGGER = pull_request targeting main +MERGE_BEFORE_PREMERGE_SAFE_CI_PASS = NO +``` + +After authorized integration reaches authoritative main, capture its exact SHA +and the Safe CI run caused by the push to main. Require that post-merge run to +pass and retain its receipt before the final governed review. Pre-merge success +cannot replace this evidence, even if a merge strategy preserves the feature +commit SHA. + +```text +POSTMERGE_SAFE_CI_REQUIRED = YES +POSTMERGE_SAFE_CI_TRIGGER = push to main +PREMERGE_CI_MAY_SUBSTITUTE_FOR_POSTMERGE_CI = NO +POSTMERGE_CI_MAY_BE_SKIPPED = NO +``` + +The existing `workflow_dispatch` trigger remains available in the workflow but +is not a substitute for either required event in this DL-05 sequence. No trigger +is invoked by this specification. Failed, cancelled, skipped, pending, missing, +or unverifiable mandatory results block promotion; they authorize no automatic +repair, retry, workflow change, merge, or acceptance. + +### Safe CI workflow and command contract + +Use the existing [Safe CI workflow](../../.github/workflows/safe-ci.yml) and its +[governing CI policy](../phase_2m/phase_2m_05_platform_quality_continuation_and_closure_authorization_gate_planning_only.md#workflow-trigger-and-permission-boundary). +The readiness-verified workflow ID is `311663833`; the specification-base Git blob +is `717eb0fc4cd54f1c1554137327c26cd2fe82e610`. These identify the baseline. +Each future receipt must identify the actual workflow source at its target SHA, +including action pins/version comments; a workflow change requires separate +scope resolution and cannot inherit this baseline silently. + +```text +SAFE_CI_WORKFLOW = .github/workflows/safe-ci.yml +SAFE_CI_WORKFLOW_NAME = Safe CI +SAFE_CI_REQUIRED_JOB = validate +SAFE_CI_DISPLAYED_JOB = Node and Python quality gates +SAFE_CI_RUNNER = ubuntu-latest +SAFE_CI_TIMEOUT_MINUTES = 30 +SAFE_CI_PERMISSIONS = contents: read +CHECKOUT_PERSIST_CREDENTIALS = false +SAFE_CI_SECRET_DEPENDENCY_REQUIRED = NO +SAFE_CI_ARTIFACT_UPLOAD_REQUIRED = NO +``` + +All twelve steps below are mandatory and ordered. A successful job label alone +is insufficient if a mandatory step did not succeed. + +| Order | Exact workflow step | Command or required setup | +| ---: | --- | --- | +| 1 | Check out repository | Pinned `actions/checkout`; `persist-credentials: false` | +| 2 | Set up Node.js 22 | Pinned `actions/setup-node`; Node `22`, package-manager cache disabled | +| 3 | Install committed Node dependencies | `npm ci` | +| 4 | Typecheck | `npm run typecheck` | +| 5 | Lint with zero warnings | `npm run lint`; zero-warning policy retained | +| 6 | Run Node unit tests | `npm run test:unit` | +| 7 | Build Next.js with telemetry disabled | `npm run build`; `NEXT_TELEMETRY_DISABLED=1` | +| 8 | Set up Python 3.13 | Pinned `actions/setup-python`; Python `3.13` | +| 9 | Install committed Python dependencies | `python -m pip install -r requirements.txt` | +| 10 | Run Python tests | `python -m pytest` | +| 11 | Validate report index | `python network_lab.py --task report-index` | +| 12 | Prove validation changed no tracked file | `git diff --exit-code` | + +Every mandatory command must exit zero. Report-index must be PASS or WARN only +under the existing optional-report policy: zero failures, zero mandatory missing, +zero unknowns, and evidence that every missing report is optional. The retained +13 optional reports are a comparison baseline, not a forced hosted count. +Validation must not change tracked files. + +Existing CI policy permits public dependency-registry access needed to materialize +the committed lockfile and requirements in the ephemeral hosted runner. It grants +no dependency updates, arbitrary packages, device/private-service access, or +repository writes. Offline product validation does not mean the dependency setup +uses no Internet connection. + +Safe CI grants no Lab1/Lab2 access, SSH, NETCONF, RESTCONF, device commands, +configuration backup/change, signing, credentials/private runtime configuration, +new live Owner authorization, provider/model API use, production execution, +queue/worker/scheduler, AI loop, or arbitrary execution authority. + +```text +SAFE_CI_LIVE_NETWORK_ACCESS = NO +SAFE_CI_CONFIG_MUTATION = NO +SAFE_CI_PROVIDER_MODEL_ACCESS = NO +SAFE_CI_SECRET_DEPENDENCY_REQUIRED = NO +``` + +### Command attestation and open P3 disposition + +`DL04_REVIEW_P3_PYTEST_ARGV_RECORDING` remains `OPEN_NON_BLOCKING`. The existing +hosted workflow does not use the affected external `run_review.py`. DL-05 must +neither reuse/copy that wrapper for its evidence procedure nor rely on its +recorded argv for exact command attestation. + +DL-05 command evidence instead binds the committed workflow definition and its +exact source/blob at the target SHA to GitHub run ID, run attempt, run-head +metadata, actual checkout/test SHA, job identity, step identity, step outcome, +and available hosted logs. Preserve literal non-path command arguments in any +captured description; workflow source and hosted execution evidence must be +distinguishable from summaries. + +```text +DL_05_REUSES_DL04_WRAPPER = NO +DL_05_DEPENDS_ON_WRAPPER_ARGV_ATTESTATION = NO +P3_AFFECTS_SAFE_CI_VALIDITY = NO +P3_REMEDIATION_REQUIRED_BEFORE_SAFE_CI = NO +P3_REMEDIATION_REQUIRED_BEFORE_DL05_ACCEPTANCE = NO +P3_MUST_BE_CLOSED_FOR_DL05_ACCEPTANCE = NO +P3_ARGV_RECORDING_FINDING_STATUS = OPEN_NON_BLOCKING +``` + +The finding may remain open beyond DL-05 unless separately prioritized. Do not +repair or close it as part of DL-05. Future work choosing to reuse the affected +argv recording must separately address the finding. The sealed DL-04 bundle and +wrapper remain unchanged; this disposition does not reopen DL-04 acceptance. + +### External CI receipts and SHA correspondence + +Retain two external read-only evidence receipts with these logical identities: + +- `DL05_PREMERGE_SAFE_CI_RECEIPT` for the required PR run. +- `DL05_POSTMERGE_SAFE_CI_RECEIPT` for the required main push run; mandatory final + acceptance evidence. + +Both receipts must include the following fields or explicitly record a missing +field and its effect on the gate. Missing mandatory proof blocks promotion. + +| Receipt group | Required evidence | +| --- | --- | +| Repository/workflow | Repository identity; workflow name, ID, path, source at the target SHA and blob identity where available; action pins/version | +| Invocation | Run ID and URL/identity, attempt, event/trigger, branch/ref, separately labeled run-head metadata, actual checkout/test ref and SHA, expected target identity | +| Pre-merge identities | PR number and URL/identity; PR head ref and `DL_05_PREMERGE_PR_HEAD_SHA`; base ref `main` and `DL_05_PREMERGE_BASE_SHA`; merge ref `refs/pull//merge` and `DL_05_PREMERGE_TEST_SHA` | +| Timing/result | Start/completion timestamps, completed state, run conclusion | +| Jobs/steps | Required job ID/name/conclusion; every mandatory step identity/name/outcome; command exit status established by hosted results/logs | +| Python | Safely available pytest summary, collected/passed/failed/error/skip outcomes where reported, exact hosted skip identities/reasons and classifications | +| Other validation | Node quality-gate outcomes, report-index result and WARN rationale, tracked-diff exit/result | +| Provenance | GitHub source URLs/identities, available supporting logs, evidence-capture procedure and capture identity/time; limitations | + +Do not substitute a historical/local DL-04 result for an unavailable hosted +result. Store no secrets, raw private runtime content, credentials, signatures, +or private live configuration. Receipts stay external; canonical documentation +records stable identities, sanitized findings, and status. No workflow artifact +upload or workflow modification is required or authorized by this contract. + +The pre-merge receipt must prove all six conditions below for the captured +run/attempt and validated PR state: + +1. `DL_05_PREMERGE_PR_HEAD_SHA` exactly equals the expected pushed Dual-Lab + branch HEAD and approved pre-merge candidate. +2. The PR base ref is authoritative `main`; record its applicable full base SHA. +3. The Safe CI `pull_request` event belongs to the exact intended PR. +4. The actual checkout/test SHA is `DL_05_PREMERGE_TEST_SHA`, corresponding to + that PR's `refs/pull//merge` at the validated state. +5. The tested synthetic merge integrates both the exact expected PR head and + the applicable authoritative main base. Retain immutable commit relationship + evidence for both; a later value of the moving merge ref is insufficient. +6. Every required Safe CI result passes for that tested merged result. This proves + the candidate integrated with its PR base, not the feature commit in isolation. + +These are mandatory predicates for a future pre-merge PASS, not current results: + +```text +DL_05_PREMERGE_PR_HEAD_SHA_MATCH = YES +DL_05_PREMERGE_BASE_REF = main +DL_05_PREMERGE_TEST_REF = refs/pull//merge +DL_05_PREMERGE_TEST_SHA_MATCH = YES +DL_05_PREMERGE_TEST_SHA_CONTAINS_EXPECTED_PR_HEAD = YES +DL_05_PREMERGE_TEST_SHA_CONTAINS_EXPECTED_BASE = YES +PREMERGE_EVIDENCE_DISTINGUISHES_HEAD_BASE_AND_TEST_SHA = YES +``` + +Record any run API `head_sha` as `SAFE_CI_PREMERGE_RUN_HEAD_METADATA_SHA`, with +its source and meaning, separately from the PR head and actual checkout/test +SHA. Run metadata alone cannot establish checkout identity. Never label the +synthetic merge SHA as the PR head SHA or require those identities to match. +The workflow ID/source, run ID/attempt/event, and every required job/step outcome +must bind to this same receipt and tested state. + +Post-merge correspondence retains literal full-SHA equality. The integrated +implementation commit is the exact authoritative main SHA after merge; the +required Safe CI event remains `push` to `main`: + +```text +DL_05_INTEGRATED_IMPLEMENTATION_COMMIT = POSTMERGE_AUTHORITATIVE_MAIN_SHA +AUTHORITATIVE_MAIN_SHA = SAFE_CI_POSTMERGE_RUN_HEAD_SHA +REVIEW_TARGET_SHA = AUTHORITATIVE_MAIN_SHA = SAFE_CI_POSTMERGE_RUN_HEAD_SHA +SAFE_CI_POSTMERGE_CHECKOUT_SHA = DL_05_INTEGRATED_IMPLEMENTATION_COMMIT +FINAL_EXACT_SHA_REVIEW_TARGET = DL_05_INTEGRATED_IMPLEMENTATION_COMMIT +``` + +The pre-merge synthetic SHA must never become the final accepted DL-05 +implementation identity. Mandatory checkout proof is required for both receipts; +post-merge run metadata does not substitute for actual checkout evidence. +Any target mismatch, moved candidate, or unexplained workflow/content difference +blocks acceptance. Never infer correspondence from a branch label or successful +run for another commit. + +### Hosted skips and retained DL-04 evidence + +Ubuntu hosted results require environment-specific classification. The local +Windows skip set is comparison evidence only. Record each actual hosted skip's +exact node identity, reason, environment applicability, and canonical-policy +basis. Valid documented environment differences may change counts; numeric +equality with DL-04's local totals is not required. Unexplained skips are never +accepted. If available hosted evidence cannot establish exact skip identities, +stop for a separately authorized evidence-resolution decision; do not infer them +from a count or change the workflow/tests inside a review task. + +```text +WINDOWS_SKIP_SET_AUTOMATICALLY_ACCEPTED_ON_UBUNTU = NO +SAFE_CI_UNEXPLAINED_SKIP_COUNT = 0 +``` + +| Retained DL-04 evidence | DL-05 classification | +| --- | --- | +| Accepted manifest | `REUSABLE_BASELINE` | +| Closure results | `REUSABLE_BASELINE` | +| Accepted Windows skip set | `BASELINE_ONLY / REQUIRES_FRESH_SAFE_CI_ENVIRONMENT_CONFIRMATION` | +| Guard baseline | `REQUIRES_FRESH_DL05_REVIEW` | +| 28-area mapping | `REQUIRES_FRESH_DL05_EXACT_SHA_REVIEW` | +| Security assertions | `REQUIRES_FRESH_DL05_EXACT_SHA_REVIEW` | +| Tracked-integrity baseline | `REQUIRES_FRESH_DL05_EXACT_SHA_REVIEW` | + +The hosted workflow does not install the external DL-04 guard. Its old denial +counts and exclusions cannot be claimed as fresh hosted observations. Fresh +DL-05 review must assess preservation at the exact integrated content without +weakening the accepted safety boundaries or reopening predecessor acceptance. + +### Fresh independent acceptance and findings gate + +DL-05 independent acceptance means a fresh, read-only, separately authorized +exact-SHA governed review task after integration into authoritative main, +successful post-merge Safe CI, and capture of its exact evidence. Use a separate +task context with a fresh review mandate and fresh inspection. A new conversation, +separate human, or different agent identity is not required. Record the actual +process and its limitations; same-model technical review must not be presented +as an attestation by a different human or agent. + +```text +EXACT_SHA_REVIEW_REQUIRED = YES +SAFE_CI_EVIDENCE_REQUIRED_BEFORE_REVIEW = YES +POSTMERGE_REVIEW_REQUIRED = YES +SEPARATE_TASK_CONTEXT_REQUIRED = YES +SEPARATE_CONVERSATION_REQUIRED = NO +SEPARATE_HUMAN_REVIEWER_REQUIRED = NO +SEPARATE_AGENT_IDENTITY_REQUIRED = NO +ZERO_UNRESOLVED_MATERIAL_FINDINGS_REQUIRED = YES +NONBLOCKING_FINDINGS_PERMITTED = YES +``` + +The fresh review must verify the authoritative main SHA and approved Dual-Lab +source/test/document content; preserved DL-02/03/04 accepted state; actual workflow +identity; post-merge exact-SHA correspondence; every required job/step PASS; +acceptable report-index; valid evidence provenance; and no unexpected content +drift, authority expansion, live access, provider/model integration, or +configuration mutation. Recheck the retained coverage/security/integrity evidence +against that exact target and accurately classify P3. Implementation, merge, +pre-merge-review, or historical review conclusions cannot replace fresh inspection. + +The retained predecessor has `DL04_UNRESOLVED_MATERIAL_FINDINGS = 0` and +`DL04_NONBLOCKING_FINDINGS = 1`. These are baseline facts. The final review must +freshly establish `DL05_UNRESOLVED_MATERIAL_FINDINGS = 0` and return PASS. +An open non-blocking finding alone does not fail this gate. A material finding +fails it; missing evidence blocks it. Either outcome stops acceptance and requires +separate resolution authority, not an in-task repair. + +### Required evidence package and documentation checkpoints + +The canonical package contains all twenty items below. It may remain external +under existing evidence practice, with stable references in the canonical record. +No future receipt is represented as already created by this specification. + +| Item | Required content | +| ---: | --- | +| 1 | Exact pre-merge candidate identity and approved scope | +| 2 | PR number and URL/identity | +| 3 | PR head ref/exact SHA; base ref/applicable SHA; PR merge ref/exact tested merge SHA | +| 4 | Pre-merge Safe CI run ID and attempt | +| 5 | `DL05_PREMERGE_SAFE_CI_RECEIPT` | +| 6 | Merge/integration result, authorization reference, method, and content comparison | +| 7 | Exact authoritative post-merge main SHA | +| 8 | Post-merge Safe CI run ID and attempt | +| 9 | `DL05_POSTMERGE_SAFE_CI_RECEIPT` | +| 10 | Actual workflow source/blob/version identity | +| 11 | All mandatory job/step outcomes | +| 12 | Hosted pytest result and exact skip classifications | +| 13 | Hosted report-index result and optional-WARN rationale | +| 14 | Hosted tracked-diff cleanliness result and reviewed repository clean state | +| 15 | Accepted DL-04 candidate/manifest and retained-baseline references | +| 16 | Fresh exact-SHA governed review result and actual reviewer/process provenance | +| 17 | Findings list, severity, material/non-blocking classification, disposition | +| 18 | Fresh zero-unresolved-material-findings determination | +| 19 | Canonical DL-05 candidate status tied to the reviewed main SHA and evidence | +| 20 | Later separate Owner-acceptance decision record tied to that candidate | + +Capture receipts and supporting records externally with stable logical identities +and provenance; retain failed attempts separately. Later records reference the +captured run/attempt and exact SHA without overwriting earlier evidence. Repository +candidate/status documentation records sanitized stable identities and requires +separate exact documentation authorization. Owner acceptance cannot precede the +canonical candidate record. Post-acceptance reconciliation records the completed +decision under its own authorization. + +Candidate and acceptance documentation commits are distinct from the validated +integrated implementation SHA. Record their actual hashes/parents externally; +never silently relabel a documentation descendant as the reviewed main target. +A proposed new acceptance target requires its own matching post-merge CI and fresh +review evidence. Any intervening change must be identified and classified before +acceptance, with no unsupported claim that the new HEAD was already validated. + +### Required state and authorization sequence + +The order below is mandatory. Each transition requires its preceding evidence +and the named separate authorization. Remote mutation, merge, fresh review, and +Owner acceptance must not be collapsed into one task. Receipt capture accompanies +each CI gate; canonical documentation writes require separately scoped authority. + +```text +DL_05_SPECIFICATION_ESTABLISHED +-> OWNER_AUTHORIZES_REMOTE_BRANCH_PUSH_AND_PR_CREATION +-> REMOTE_BRANCH_PUSHED +-> PR_CREATED_TARGETING_MAIN +-> PREMERGE_SAFE_CI_RUNS_ON_PR_MERGED_RESULT_REF +-> PREMERGE_SAFE_CI_PASS +-> FRESH_PREMERGE_CI_AND_INTEGRATION_REVIEW +-> OWNER_AUTHORIZES_MERGE +-> MERGE_TO_AUTHORITATIVE_MAIN +-> POSTMERGE_MAIN_SHA_CAPTURED +-> POSTMERGE_SAFE_CI_RUNS_ON_EXACT_MAIN_SHA +-> POSTMERGE_SAFE_CI_PASS +-> POSTMERGE_CI_EVIDENCE_CAPTURED +-> FRESH_EXACT_SHA_DL05_GOVERNED_REVIEW +-> DL_05_REVIEW_PASS +-> CANONICAL_DL05_CANDIDATE_STATUS_RECORDED +-> OWNER_ACCEPTANCE +-> POST_ACCEPTANCE_DOCUMENTATION_RECONCILIATION +-> DL_05_ACCEPTED +``` + +Recheck trusted remote, source/target refs, exact authorized candidate, clean +status, and policy before each future remote mutation. Repository-mandated +post-merge validation remains required under the later task's offline boundary; +local results cannot replace hosted post-merge CI. A failed or missing gate stops +the sequence. This document grants no blanket execution, merge, retry, cleanup, +conflict repair, or future-phase authorization. + +### Owner acceptance and offline completion semantics + +Owner acceptance is a separate decision after every requirement below is proven. +These are future gate predicates, not results of this specification task: + +```text +INTEGRATED_IMPLEMENTATION_COMMIT_ESTABLISHED = YES +POSTMERGE_SAFE_CI_PASS = YES +SAFE_CI_EXACT_SHA_MATCH = YES +FRESH_EXACT_SHA_GOVERNED_REVIEW = PASS +UNRESOLVED_MATERIAL_FINDINGS = 0 +EVIDENCE_PROVENANCE = PASS +CANONICAL_CANDIDATE_RECORD = ESTABLISHED +``` + +Before Owner acceptance, `DL_05_ACCEPTED = NO` and +`COMPLETE_DUAL_LAB_MVP = NO`. A review PASS or green CI alone cannot set either +acceptance status. Successful Owner acceptance completes the bounded offline +Dual-Lab MVP; subsequent documentation reconciliation records the decision and +finishes the canonical `DL_05_ACCEPTED` state. Reconciliation cannot manufacture +acceptance or validate a different implementation SHA. + +The following values apply only after successful DL-05 Owner acceptance: + +```text +COMPLETE_DUAL_LAB_MVP = YES +OFFLINE_DUAL_LAB_MVP_ACCEPTED = YES +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +PROVIDER_MODEL_INTEGRATION_AUTHORIZED = NO +``` + +Offline acceptance authorizes no new live query and changes no Stage-2 live-proof +or closure semantics. Any future Dual-Lab live proof remains a separately gated +later capability. No exact DL-06 execution contract is established here; a future +readiness/specification/exact Owner-authorization flow is required before live +work. No DL-06 procedure, command, target credential, or live approval is invented. + +```text +DL_06_SEPARATE_LIVE_AUTHORIZATION_REQUIRED = YES +DL_06_EXECUTION_CONTRACT_STATUS = NOT_ESTABLISHED +``` + +### Current specification result and next Owner decision + +Documentation review checks the conclusion, exact accepted identities, integrated +commit definition, both CI gates, SHA correspondence, P3 disposition, reviewer +meaning, twenty-item evidence package, ordered authorization boundaries, and +conditional offline completion. Local links, whitespace, one-file scope, preserved +predecessor contracts/evidence, and explicit no-live status are checked before +commit. Application validation, Safe CI, integration, and acceptance are not run. + +The following is the current state after this specification, not the future +completion predicates above: + +```text +DL_02_ACCEPTED = YES +DL_03_ACCEPTED = YES +DL_04_ACCEPTED = YES +DL_05_SPECIFICATION_STATUS = ESTABLISHED +DL_05_STARTED = NO +REMOTE_BRANCH_PUSHED = NO +PR_CREATED = NO +PREMERGE_SAFE_CI_RUN = NO +MERGED_TO_MAIN = NO +POSTMERGE_SAFE_CI_RUN = NO +SAFE_CI_RUN = NO +DL_05_ACCEPTED = NO +COMPLETE_DUAL_LAB_MVP = NO +P3_ARGV_RECORDING_FINDING_STATUS = OPEN_NON_BLOCKING +LIVE_AUTHORITY_GRANTED = NO +LIVE_READINESS_GRANTED = NO +CONFIGURATION_MUTATION_AUTHORIZED = NO +NEXT_REQUIRED_OWNER_DECISION = AUTHORIZE_DL_05_REMOTE_BRANCH_PUSH_AND_PR_CREATION +``` diff --git a/tests/dual_lab/test_dual_lab_vrrp_query_contract.py b/tests/dual_lab/test_dual_lab_vrrp_query_contract.py new file mode 100644 index 0000000..2a2ac6e --- /dev/null +++ b/tests/dual_lab/test_dual_lab_vrrp_query_contract.py @@ -0,0 +1,437 @@ +"""Synthetic DL-01 contract checks; no runtime or live entrypoint invocation.""" + +import ast +from copy import deepcopy +from dataclasses import FrozenInstanceError, fields, replace +import inspect +import json +from pathlib import Path + +import pytest + +from validation_framework import dual_lab_vrrp_query_contract as m +from validation_framework import stage2_vrrp_readonly_contract as s2 + + +LAB1 = "target.mikrotik.lab01" +LAB2 = "target.mikrotik.lab02" +SENTINEL = "synthetic-secret-signature-path-stdout-exception" +CATEGORIES = ( + "PREFLIGHT_TARGET_BUNDLE_INVALID", + "PREFLIGHT_AUTHORIZATION_INVALID", + "PREFLIGHT_AUTHORIZATION_NOT_DISTINCT", + "PREFLIGHT_STARTUP_BINDING_UNAVAILABLE", + "CANONICAL_EVIDENCE_VALIDATION_FAILED", + "INVALID_REQUEST_INPUT", + "INVALID_AUTHORIZATION_INPUT", + "INVALID_TRUSTED_CONFIGURATION", + "TRUSTED_RUNTIME_FAILED", + "OUTPUT_RENDER_FAILED", + "INTERNAL_FAILURE", +) + + +def query(): + return {"schema_version": "dual-lab-vrrp-query.v1", "query_id": "query.synthetic-01", + "operation_id": "mikrotik.vrrp_status", "execution_order": "LAB1_THEN_LAB2"} + + +def record(): + return {"instance_name": "vrrp-synthetic", "vrid": 88, "priority": 100, + "interval_ms": 1000, "version": 3, "running": False, "role": "BACKUP", + "disabled": False, "invalid": False} + + +def evidence(target): + return {"schema_version": "1.0", "operation_id": "mikrotik.vrrp_status", + "run_id": "run.synthetic", "target_ref": target, + "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": [record()]} + + +def success(target): + return {"status": "SUCCESS", "target_ref": target, "evidence": evidence(target)} + + +def failure(target, category="INTERNAL_FAILURE"): + return {"status": "FAILURE", "target_ref": target, "failure_category": category} + + +def aggregate(): + return {**query(), "lab1": success(LAB1), "lab2": success(LAB2)} + + +def canonical(raw): + return json.dumps(raw, ensure_ascii=False, allow_nan=False, + sort_keys=True, separators=(",", ":")).encode("utf-8") + + +def rejected(parser, raw): + with pytest.raises(m.DualLabContractError) as caught: + parser(raw) + error = caught.value + assert error.args == ("invalid Dual-Lab contract",) + assert error.__cause__ is None + assert error.__context__ is None + assert SENTINEL not in str(error) + repr(error) + assert not isinstance(error, (m.TargetSuccess, m.TargetFailure, m.DualLabVrrpAggregate)) + return error + + +@pytest.mark.parametrize("left", [success, failure]) +@pytest.mark.parametrize("right", [success, failure]) +def test_all_outcome_combinations_round_trip(left, right): + raw = {**query(), "lab1": left(LAB1), "lab2": right(LAB2)} + value = m.parse_aggregate(raw) + assert value.to_dict() == raw + assert value.to_canonical_bytes() == canonical(raw) + assert m.parse_aggregate_canonical_json(value.to_canonical_bytes()) == value + assert value.execution_authorized is False + assert {f.name for f in fields(value)} == { + "schema_version", "query_id", "operation_id", "execution_order", "lab1", "lab2"} + + +def test_minimum_query_has_fixed_implicit_pair_and_canonical_identity(): + value = m.parse_query(query()) + assert {f.name for f in fields(value)} == set(query()) + assert value.to_dict() == query() + assert value.to_canonical_bytes() == canonical(query()) + assert m.parse_query_canonical_json(value.to_canonical_bytes()) == value + assert value.execution_authorized is False + + +@pytest.mark.parametrize("query_id", ["", " ", "query.", "query.1bad", "wrong.synthetic", + "query.a b", "query.a\nb", "query.a\x00", "query.a\t", "query.a\r", "query.a/b", + "query.a\\b", "query.c:\\private", "query...", "query.é", "query.a@host", + "query.a?token=x", "query.a;reboot", "query." + "a" * 155, None, True, 1, [], {}]) +@pytest.mark.parametrize("factory,parser", [(query, m.parse_query), (aggregate, m.parse_aggregate)]) +def test_invalid_query_id_rejects_without_result(factory, parser, query_id): + raw = factory() + raw["query_id"] = query_id + rejected(parser, raw) + + +@pytest.mark.parametrize("query_id", ["query.a", "query.synthetic-01.part_a", "query." + "a" * 154]) +def test_query_identifier_bound_and_existing_logical_grammar(query_id): + raw = query() + raw["query_id"] = query_id + assert m.parse_query_canonical_json(canonical(raw)).query_id == query_id + + +@pytest.mark.parametrize("field,value", [ + ("schema_version", "1.0"), ("schema_version", True), ("schema_version", 1), + ("operation_id", "mikrotik.vrrp_set"), ("operation_id", None), ("operation_id", 1), + ("execution_order", "LAB2_THEN_LAB1"), ("execution_order", "PARALLEL"), + ("execution_order", True), ("execution_order", [])]) +@pytest.mark.parametrize("factory,parser", [(query, m.parse_query), (aggregate, m.parse_aggregate)]) +def test_identity_is_exact(factory, parser, field, value): + raw = factory() + raw[field] = value + rejected(parser, raw) + + +@pytest.mark.parametrize("field", list(aggregate())) +def test_every_aggregate_field_required(field): + raw = aggregate() + del raw[field] + rejected(m.parse_aggregate, raw) + + +@pytest.mark.parametrize("field", list(query())) +def test_every_query_field_required(field): + raw = query() + del raw[field] + rejected(m.parse_query, raw) + + +@pytest.mark.parametrize("field", ["timestamp", "duration", "health", "healthy", "pair_health", + "failover_ready", "split_brain", "correct", "recommended_action", "remediation", "verdict", + "authorization_id", "authorization_ref", "raw_output", "stdout", "password", "credential", + "private_key", "signature", "envelope", "private_path", "model_output", "traceback", + "exception", "replay_consumed", "targets", "lab3", "runtime_configuration", "command"]) +@pytest.mark.parametrize("factory,parser", [(query, m.parse_query), (aggregate, m.parse_aggregate)]) +def test_unknown_and_prohibited_top_level_fields_rejected(factory, parser, field): + raw = factory() + raw[field] = SENTINEL + rejected(parser, raw) + + +@pytest.mark.parametrize("target", [LAB1, LAB2]) +@pytest.mark.parametrize("factory", [success, failure]) +def test_tagged_variants_standalone_round_trip(target, factory): + raw = factory(target) + value = m.parse_target_result(raw) + assert {f.name for f in fields(value)} == set(raw) + assert value.to_dict() == raw + assert value.execution_authorized is False + assert m.parse_target_result_canonical_json(value.to_canonical_bytes()) == value + + +@pytest.mark.parametrize("category", CATEGORIES) +@pytest.mark.parametrize("target", [LAB1, LAB2]) +def test_all_and_only_approved_failure_values(category, target): + value = m.parse_target_result(failure(target, category)) + assert type(value.failure_category) is m.FailureCategory + assert value.failure_category.value == category + assert m.parse_target_result_canonical_json(value.to_canonical_bytes()) == value + + +def test_exact_enum_and_six_s2_identifiers_without_importing_entrypoint(): + assert tuple(item.value for item in m.FailureCategory) == CATEGORIES + assert tuple(item.name for item in m.FailureCategory) == CATEGORIES + source = Path(s2.__file__).with_name("stage2_vrrp_readonly_live_entrypoint.py").read_text(encoding="utf-8") + tree = ast.parse(source) + enum = next(node for node in tree.body if isinstance(node, ast.ClassDef) + and node.name == "Stage2VrrpLiveEntrypointFailure") + identifiers = tuple(node.value.value for node in enum.body if isinstance(node, ast.Assign)) + assert identifiers == CATEGORIES[5:] + + +@pytest.mark.parametrize("category", ["UNKNOWN", SENTINEL, "PREFLIGHT_ENVELOPE_EXPIRED", + "PREFLIGHT_CREDENTIAL_MISMATCH", "INVALID_QUERY", "INVALID_EVIDENCE", None, 1, True, + ["INTERNAL_FAILURE"], m.FailureCategory.INTERNAL_FAILURE]) +def test_unknown_nonstring_and_exception_failure_categories_rejected(category): + rejected(m.parse_target_result, failure(LAB1, category)) + + +@pytest.mark.parametrize("factory", [success, failure]) +@pytest.mark.parametrize("target", ["target.mikrotik.lab03", "target.other", "", LAB1 + " ", + 1, True, None, [LAB1]]) +def test_arbitrary_target_and_wrong_types_rejected(factory, target): + raw = factory(LAB1) + raw["target_ref"] = target + rejected(m.parse_target_result, raw) + + +@pytest.mark.parametrize("status", ["success", "PASS", "WARN", "", None, True, 1, []]) +def test_invalid_tag_rejected(status): + raw = success(LAB1) + raw["status"] = status + rejected(m.parse_target_result, raw) + + +@pytest.mark.parametrize("factory", [success, failure]) +def test_missing_mixed_and_unknown_variant_fields(factory): + raw = factory(LAB1) + for field in raw: + missing = deepcopy(raw) + del missing[field] + rejected(m.parse_target_result, missing) + for field in ("failure_category" if factory is success else "evidence", "raw_stdout", + "authorization_id", "exception", "health"): + rejected(m.parse_target_result, {**raw, field: SENTINEL}) + + +@pytest.mark.parametrize("left,right", [(LAB2, LAB1), (LAB1, LAB1), (LAB2, LAB2)]) +@pytest.mark.parametrize("factory", [success, failure]) +def test_aggregate_slot_target_binding(left, right, factory): + rejected(m.parse_aggregate, {**query(), "lab1": factory(left), "lab2": factory(right)}) + + +@pytest.mark.parametrize("field,value", [("target_ref", LAB2), ("operation_id", "other.operation"), + ("command_policy_version", "policy.other"), ("duration_ms", True), ("retry_count", 1), + ("attempt_count", "1"), ("records", {}), ("raw_output_sha256", "bad")]) +def test_malformed_or_mismatched_evidence_rejected(field, value): + raw = success(LAB1) + raw["evidence"][field] = value + rejected(m.parse_target_result, raw) + + +@pytest.mark.parametrize("field", ["raw_stdout", "password", "signature", "authorization_id", + "private_path", "model_output", "health"]) +def test_prohibited_data_cannot_enter_evidence_or_record(field): + for location in ("evidence", "record"): + raw = success(LAB1) + container = raw["evidence"] if location == "evidence" else raw["evidence"]["records"][0] + container[field] = SENTINEL + rejected(m.parse_target_result, raw) + + +def test_existing_evidence_schema_and_empty_multiple_records_preserved(): + for records in ([], [record()], [record(), {**record(), "role": "MASTER", "priority": 150}]): + raw = success(LAB1) + raw["evidence"]["records"] = records + original = s2.parse_stage2_vrrp_observation_evidence(raw["evidence"]) + result = m.parse_target_result(raw) + assert type(result.evidence) is s2.Stage2VrrpObservationEvidence + assert result.evidence == original + assert result.evidence.to_canonical_bytes() == original.to_canonical_bytes() + assert result.to_dict()["evidence"] == raw["evidence"] + assert not hasattr(result, "health") + + +@pytest.mark.parametrize("path,key", [("aggregate", "query_id"), ("target", "status"), + ("evidence", "duration_ms"), ("record", "vrid")]) +def test_duplicate_keys_rejected_at_every_depth(path, key): + raw = canonical(aggregate()) + needles = {"aggregate": b'"query_id":"query.synthetic-01"', "target": b'"status":"SUCCESS"', + "evidence": b'"duration_ms":1', "record": b'"vrid":88'} + needle = needles[path] + rejected(m.parse_aggregate_canonical_json, raw.replace(needle, needle + b"," + needle, 1)) + + +@pytest.mark.parametrize("factory,parser", [(query, m.parse_query_canonical_json), + (lambda: success(LAB1), m.parse_target_result_canonical_json), + (lambda: failure(LAB2), m.parse_target_result_canonical_json), + (aggregate, m.parse_aggregate_canonical_json)]) +def test_strict_json_framing_types_limits_and_canonical_bytes(factory, parser): + raw = canonical(factory()) + first_pair = raw[1:].split(b",", 1)[0] + invalid = [None, True, 1, {}, raw.decode(), bytearray(raw), memoryview(raw), b"", b"\xff", + b"\xef\xbb\xbf" + raw, b" " + raw, raw + b"\n", b"null", b"[]", b"{}", + b'{"x":NaN}', b'{"x":Infinity}', b'{"x":-Infinity}', b"x" * 100_000, + b'{"x":' + b"[" * 2000 + b"0" + b"]" * 2000 + b"}", + b"{" + first_pair + b"," + raw[1:], json.dumps(factory(), indent=2).encode()] + for value in invalid: + rejected(parser, value) + + +@pytest.mark.parametrize("factory,parser", [(query, m.parse_query), (aggregate, m.parse_aggregate), + (lambda: success(LAB1), m.parse_target_result)]) +def test_plain_exact_containers_only(factory, parser): + class MappingSubclass(dict): + pass + for value in (MappingSubclass(factory()), None, [], True, 1, canonical(factory())): + rejected(parser, value) + + +def test_string_subclasses_do_not_bypass_fixed_identity(): + class StringSubclass(str): + pass + for field in query(): + raw = query() + raw[field] = StringSubclass(raw[field]) + rejected(m.parse_query, raw) + for field in ("status", "target_ref", "failure_category"): + raw = failure(LAB1) + raw[field] = StringSubclass(raw[field]) + rejected(m.parse_target_result, raw) + + +def test_immutability_direct_construction_and_input_output_isolation(): + raw = aggregate() + value = m.parse_aggregate(raw) + raw["query_id"] = "query.changed" + raw["lab1"]["evidence"]["records"][0]["priority"] = 255 + exported = value.to_dict() + exported["lab2"]["evidence"]["records"].clear() + assert value.query_id == "query.synthetic-01" + assert value.lab1.evidence.records[0].priority == 100 + assert len(value.lab2.evidence.records) == 1 + for obj, field, replacement in ((value, "query_id", "query.changed"), + (value.lab1, "target_ref", LAB2), (value.lab1.evidence.records[0], "priority", 255), + (m.parse_query(query()), "query_id", "query.changed"), + (m.parse_target_result(failure(LAB1)), "status", "SUCCESS")): + with pytest.raises(FrozenInstanceError): + setattr(obj, field, replacement) + assert not hasattr(obj, "__dict__") + rejected(lambda _: replace(value, lab1=value.lab2), None) + rejected(lambda _: replace(value, query_id="bad"), None) + rejected(lambda _: m.TargetSuccess("FAILURE", LAB1, value.lab1.evidence), None) + rejected(lambda _: m.TargetFailure("FAILURE", LAB1, "INTERNAL_FAILURE"), None) + rejected(lambda _: m.TargetFailure("SUCCESS", LAB1, m.FailureCategory.INTERNAL_FAILURE), None) + + +def test_direct_success_detaches_evidence_and_revalidates_forged_records(): + original = s2.parse_stage2_vrrp_observation_evidence(evidence(LAB1)) + result = m.TargetSuccess("SUCCESS", LAB1, original) + assert result.evidence == original and result.evidence is not original + object.__setattr__(original.records[0], "priority", 999) + assert result.evidence.records[0].priority == 100 + rejected(lambda _: m.TargetSuccess("SUCCESS", LAB1, original), None) + + +@pytest.mark.parametrize("location,field,bad", [("aggregate", "query_id", SENTINEL), + ("target", "status", "FAILURE"), ("evidence", "operation_id", "other.operation"), + ("record", "priority", 999), ("evidence", "duration_ms", object()), + ("evidence", "run_id", "run." + "x" * 100_000)], + ids=["query", "tag", "operation", "record", "non-json", "oversized"]) +def test_serialization_rechecks_objects_altered_after_construction(location, field, bad): + value = m.parse_aggregate(aggregate()) + obj = {"aggregate": value, "target": value.lab1, "evidence": value.lab1.evidence, + "record": value.lab1.evidence.records[0]}[location] + object.__setattr__(obj, field, bad) + rejected(lambda _: value.to_dict(), None) + rejected(lambda _: value.to_canonical_bytes(), None) + + +def test_failure_and_query_serialization_revalidate_and_missing_attributes_reject(): + value = m.parse_target_result(failure(LAB1)) + object.__setattr__(value, "failure_category", SENTINEL) + rejected(lambda _: value.to_canonical_bytes(), None) + value = m.parse_query(query()) + object.__delattr__(value, "operation_id") + rejected(lambda _: value.to_canonical_bytes(), None) + + +def test_subclassed_and_uninitialized_objects_rejected(): + class EvidenceSubclass(s2.Stage2VrrpObservationEvidence): + pass + class ResultSubclass(m.TargetSuccess): + pass + for invalid in (object.__new__(EvidenceSubclass), object.__new__(s2.Stage2VrrpObservationEvidence), + {}, canonical(evidence(LAB1))): + rejected(lambda value: m.TargetSuccess("SUCCESS", LAB1, value), invalid) + valid = m.parse_aggregate(aggregate()) + rejected(lambda _: replace(valid, lab1=object.__new__(ResultSubclass)), None) + + +def test_determinism_unicode_and_sanitized_representations(capsys): + raw = aggregate() + raw["lab1"]["evidence"]["records"][0]["instance_name"] = "vrrp-é" + reverse = {key: raw[key] for key in reversed(raw)} + value = m.parse_aggregate(raw) + assert value.to_canonical_bytes() == m.parse_aggregate(reverse).to_canonical_bytes() + assert m.parse_aggregate_canonical_json(value.to_canonical_bytes()) == value + for obj in (value, value.lab1, m.parse_target_result(failure(LAB2)), m.parse_query(query())): + assert repr(obj) == f"{type(obj).__name__}()" + assert str(obj) == repr(obj) + assert "query.synthetic" not in repr(obj) + assert LAB1 not in repr(obj) + try: + raise ValueError(SENTINEL) + except ValueError: + rejected(m.parse_aggregate, {"exception": SENTINEL}) + assert capsys.readouterr() == ("", "") + + +def test_exact_public_surface_and_no_execution_capabilities(): + expected = {"FailureCategory", "DualLabContractError", "DualLabVrrpQuery", + "TargetSuccess", "TargetFailure", "TargetResult", "DualLabVrrpAggregate", + "parse_query", "parse_target_result", "parse_aggregate", "parse_query_canonical_json", + "parse_target_result_canonical_json", "parse_aggregate_canonical_json"} + assert set(m.__all__) == expected + assert {name for name in vars(m) if not name.startswith("_")} == expected + tree = ast.parse(inspect.getsource(m)) + imports = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + imports.update(alias.name for alias in node.names) + elif isinstance(node, ast.ImportFrom): + imports.add(node.module) + if node.module == "validation_framework": + assert [alias.name for alias in node.names] == ["stage2_vrrp_readonly_contract"] + assert imports == {"dataclasses", "enum", "functools", "json", "re", "validation_framework"} + calls = {node.func.id for node in ast.walk(tree) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)} + assert not calls & {"open", "exec", "eval", "print", "__import__", "run_stage2_vrrp_live_once"} + assert not any(isinstance(node, (ast.AsyncFunctionDef, ast.Await)) for node in ast.walk(tree)) + + +def test_serde_failure_returns_no_aggregate_or_target_failure(monkeypatch): + value = m.parse_aggregate(aggregate()) + def fail(*args, **kwargs): + raise RuntimeError(SENTINEL) + monkeypatch.setattr(m._json, "dumps", fail) + rejected(lambda _: value.to_canonical_bytes(), None) + + +def test_final_canonical_reparse_failure_returns_no_output(monkeypatch): + value = m.parse_aggregate(aggregate()) + def fail(*args, **kwargs): + raise RuntimeError(SENTINEL) + monkeypatch.setattr(m, "parse_aggregate_canonical_json", fail) + rejected(lambda _: value.to_canonical_bytes(), None) diff --git a/tests/dual_lab/test_dual_lab_vrrp_query_orchestrator.py b/tests/dual_lab/test_dual_lab_vrrp_query_orchestrator.py new file mode 100644 index 0000000..cadd0b3 --- /dev/null +++ b/tests/dual_lab/test_dual_lab_vrrp_query_orchestrator.py @@ -0,0 +1,615 @@ +"""DL-02 synthetic tests: private entrypoint fake only, no live authority.""" + +import ast +from dataclasses import FrozenInstanceError, fields, replace +import hashlib +import inspect +import json +from pathlib import Path +import socket +import sqlite3 +from types import SimpleNamespace + +import pytest + +from validation_framework import dual_lab_vrrp_query_orchestrator as m +from validation_framework import dual_lab_vrrp_query_contract as d +from validation_framework import stage2_vrrp_readonly_contract as c +from validation_framework import stage2_authorization_envelope_ledger as a +from validation_framework import stage2_trusted_runtime_composition as r +from validation_framework import stage2_vrrp_readonly_live_entrypoint as entry + + +LABS = ("target.mikrotik.lab01", "target.mikrotik.lab02") +NOW = 1_800_000_000 +SENTINEL = "synthetic-secret-raw-stdout-signature-private-path-exception" +F = d.FailureCategory + + +def canonical(value): + return json.dumps(value, sort_keys=True, separators=(",", ":"), + ensure_ascii=False, allow_nan=False).encode("utf-8") + + +def configuration(number, root): + first = r._target.Stage2FixedTargetEndpoint(LABS[0], "192.0.2.1", 22, "SSH", True) + second = r._target.Stage2FixedTargetEndpoint(LABS[1], "192.0.2.2", 22, "SSH", True) + registry = r._target.Stage2FixedTargetRegistry(first, second) + identity = "win32-fileid-v1:" + "1" * 16 + ":" + "2" * 32 + return r.Stage2TrustedRuntimeConfiguration( + registry, r"c:\synthetic\root.json", identity, "a" * 64, + a.Stage2ReplayLedgerConfiguration( + root / "never-opened.db", "22345678-1234-4234-8234-123456789abc", + (Path(__file__).resolve().parents[2],)), + r._host.Stage2KnownHostSourceConfiguration( + rf"c:\synthetic\lab{number}.json", identity, "b" * 64, + registry.lookup(LABS[number - 1])), + r._windows.Stage2TrustedWindowsCredentialConfiguration( + f"locator.stage2.mikrotik.lab0{number}.readonly", f"synthetic-only-lab{number}")) + + +def request(number): + return c.Stage2VrrpObservationRequest( + "1.0", "mikrotik.vrrp_status", f"run.synthetic-lab{number}", LABS[number - 1], + f"credential.mikrotik.lab0{number}", f"authorization.synthetic-lab{number}", True) + + +def envelope(req, number): + return a.Stage2AuthorizationEnvelope( + "1.0", f"{number}2345678-1234-4234-8234-123456789abc", req.operation_id, + hashlib.sha256(req.to_canonical_bytes()).hexdigest(), req.authorization_ref, + req.target_ref, req.credential_ref, NOW - 10, NOW + 100, 1) + + +def evidence(req, number): + record = c.NormalizedVrrpRecord( + "vrrp-synthetic", 88, 150 if number == 1 else 100, 1000, 3, + number == 1, "MASTER" if number == 1 else "BACKUP", False, False) + return c.Stage2VrrpObservationEvidence( + "1.0", req.operation_id, req.run_id, req.target_ref, req.authorization_ref, + c.VRRP_COMMAND_POLICY_VERSION, 1, 0, 1 if number == 1 else 60_000, + 0, "a" * 64, (record,)).to_canonical_bytes() + + +@pytest.fixture(autouse=True) +def no_real_execution(monkeypatch): + attempted = [] + + def forbidden(*args, **kwargs): + attempted.append("forbidden execution boundary") + raise AssertionError("real execution is forbidden in DL-02 tests") + + for obj, name in ( + (m, "_invoke_once"), (entry, "run_stage2_vrrp_live_once"), (entry, "_execute"), + (r, "execute_stage2_vrrp_trusted_runtime"), + (r._root, "acquire_owner_trust_root_configuration"), + (r._owner.Stage2OwnerVerifier, "verify"), + (a.Stage2ReplayLedger, "consume"), + (r._host, "acquire_stage2_known_host_snapshot"), + (r._windows, "build_stage2_windows_credential_backend"), + (r._transport, "execute_stage2_pinned_ssh_command"), + (socket, "create_connection"), (socket, "getaddrinfo"), (sqlite3, "connect"), + ): + monkeypatch.setattr(obj, name, forbidden) + monkeypatch.setattr(m, "_utc_now", lambda: NOW) + yield + assert attempted == [] + + +@pytest.fixture +def setup(monkeypatch, tmp_path): + requests = [request(1), request(2)] + bundles = [m.DualLabTargetBundle(req.to_canonical_bytes(), envelope(req, n).to_canonical_bytes(), + configuration(n, tmp_path)) + for n, req in enumerate(requests, 1)] + state = SimpleNamespace( + query=d.DualLabVrrpQuery("dual-lab-vrrp-query.v1", "query.synthetic", "mikrotik.vrrp_status", + "LAB1_THEN_LAB2"), + bundles=bundles, calls=[], ledger=[], active=False, + outputs=[evidence(req, n) for n, req in enumerate(requests, 1)], + errors=[None, None]) + + def fake(request_bytes, envelope_bytes, config): + assert not state.active, "S2-RO-11 calls overlapped" + state.active = True + target = c.parse_stage2_vrrp_request_canonical_json(request_bytes).target_ref + index = LABS.index(target) + state.calls.append((request_bytes, envelope_bytes, config)) + state.ledger.append((target, "begin")) + try: + if state.errors[index] is not None: + raise state.errors[index] + return state.outputs[index] + finally: + state.ledger.append((target, "end")) + state.active = False + + monkeypatch.setattr(m, "_invoke_once", fake) + state.call = lambda: m.run_dual_lab_vrrp_query(state.query, *state.bundles) + return state + + +def outcomes(value): + return value.lab1, value.lab2 + + +def assert_failure(value, code): + assert type(value) is d.TargetFailure + assert value.failure_category is code + assert value.to_dict() == {"status": "FAILURE", "target_ref": value.target_ref, + "failure_category": code.value} + + +def change_request(state, index, **changes): + raw = json.loads(state.bundles[index].request_bytes) + raw.update(changes) + state.bundles[index] = replace(state.bundles[index], request_bytes=canonical(raw)) + + +def change_envelope(state, index, **changes): + raw = json.loads(state.bundles[index].envelope_bytes) + raw.update(changes) + state.bundles[index] = replace(state.bundles[index], envelope_bytes=canonical(raw)) + + +def test_success_order_overlap_call_limit_and_canonical_roundtrip(setup): + value = setup.call() + assert type(value) is d.DualLabVrrpAggregate + assert [item.status for item in outcomes(value)] == ["SUCCESS", "SUCCESS"] + assert setup.ledger == [(LABS[0], "begin"), (LABS[0], "end"), + (LABS[1], "begin"), (LABS[1], "end")] + assert not setup.active and len(setup.calls) == 2 + for index, (req, env, config) in enumerate(setup.calls): + assert req is setup.bundles[index].request_bytes + assert env is setup.bundles[index].envelope_bytes + assert config == setup.bundles[index].trusted_configuration + assert config is not setup.bundles[index].trusted_configuration + assert value.to_canonical_bytes() == canonical(value.to_dict()) + assert d.parse_aggregate_canonical_json(value.to_canonical_bytes()) == value + assert set(value.to_dict()) == {"schema_version", "query_id", "operation_id", "execution_order", "lab1", "lab2"} + # Different durations are observations, not skew/health acceptance rules. + assert value.lab1.evidence.duration_ms == 1 + assert value.lab2.evidence.duration_ms == 60_000 + assert value.execution_authorized is False + + +@pytest.mark.parametrize("invalid_target", [None, 0, 1]) +def test_both_preflights_finish_before_any_invocation(setup, monkeypatch, invalid_target): + events = [] + original_preflight, original_invoke = m._preflight, m._invoke_once + if invalid_target is not None: + setup.bundles[invalid_target] = replace(setup.bundles[invalid_target], trusted_configuration=None) + + def preflight(bundle, target): + result = original_preflight(bundle, target) + events.append(target) + return result + + def invoke(*args): + assert events == list(LABS) + return original_invoke(*args) + + monkeypatch.setattr(m, "_preflight", preflight) + monkeypatch.setattr(m, "_invoke_once", invoke) + value = setup.call() + assert events == list(LABS) + if invalid_target is not None: + assert_failure(outcomes(value)[invalid_target], F.PREFLIGHT_STARTUP_BINDING_UNAVAILABLE) + assert outcomes(value)[1 - invalid_target].status == "SUCCESS" + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("code", list(entry.Stage2VrrpLiveEntrypointFailure)) +def test_six_runtime_failures_preserved_independently_without_retry(setup, index, code, capsys): + error = entry.Stage2VrrpLiveEntrypointError(code) + error.args = (SENTINEL,) + setup.errors[index] = error + value = setup.call() + assert_failure(outcomes(value)[index], F(code.value)) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert setup.ledger == [(LABS[0], "begin"), (LABS[0], "end"), + (LABS[1], "begin"), (LABS[1], "end")] + assert len(setup.calls) == 2 and not setup.active + assert SENTINEL not in value.to_canonical_bytes().decode() + assert capsys.readouterr() == ("", "") + + +def test_both_runtime_failures_keep_separate_categories(setup): + setup.errors = [entry.Stage2VrrpLiveEntrypointError(entry.Stage2VrrpLiveEntrypointFailure.INVALID_REQUEST_INPUT), + entry.Stage2VrrpLiveEntrypointError(entry.Stage2VrrpLiveEntrypointFailure.TRUSTED_RUNTIME_FAILED)] + value = setup.call() + assert_failure(value.lab1, F.INVALID_REQUEST_INPUT) + assert_failure(value.lab2, F.TRUSTED_RUNTIME_FAILED) + assert len(setup.calls) == 2 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("kind", ["unexpected", "wrong-code", "missing-code", "subclass"]) +def test_unexpected_or_forged_boundary_error_is_internal(setup, index, kind): + class Subclass(entry.Stage2VrrpLiveEntrypointError): + pass + error = entry.Stage2VrrpLiveEntrypointError(entry.Stage2VrrpLiveEntrypointFailure.TRUSTED_RUNTIME_FAILED) + if kind == "unexpected": + error = RuntimeError(SENTINEL) + elif kind == "wrong-code": + error.code = SENTINEL + elif kind == "missing-code": + del error.code + else: + error = Subclass(entry.Stage2VrrpLiveEntrypointFailure.TRUSTED_RUNTIME_FAILED) + setup.errors[index] = error + value = setup.call() + assert_failure(outcomes(value)[index], F.INTERNAL_FAILURE) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert SENTINEL not in value.to_canonical_bytes().decode() + repr(value) + assert len(setup.calls) == 2 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("kind", ["absent", "lookalike", "subclass", "uninitialized", "swapped"]) +def test_invalid_bundle_only_skips_its_target(setup, index, kind): + class Subclass(m.DualLabTargetBundle): + pass + setup.bundles[index] = { + "absent": None, "lookalike": {}, "subclass": object.__new__(Subclass), + "uninitialized": object.__new__(m.DualLabTargetBundle), + "swapped": setup.bundles[1 - index], + }[kind] + value = setup.call() + assert_failure(outcomes(value)[index], F.PREFLIGHT_TARGET_BUNDLE_INVALID) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("field,value", [ + ("operation_id", "mikrotik.vrrp_set"), ("credential_ref", "credential.other"), + ("target_ref", "target.mikrotik.lab03"), ("read_only", False), + ("schema_version", "2.0"), ("run_id", None), ("command", SENTINEL), +]) +def test_invalid_request_field_fails_before_authorization(setup, index, field, value): + change_request(setup, index, **{field: value}) + setup.bundles[index] = replace(setup.bundles[index], envelope_bytes=None) + result = setup.call() + assert_failure(outcomes(result)[index], F.PREFLIGHT_TARGET_BUNDLE_INVALID) + assert outcomes(result)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("field", ["target_ref", "credential_ref"]) +def test_other_lab_request_binding_rejected(setup, index, field): + other = request(2 - index) + change_request(setup, index, **{field: getattr(other, field)}) + value = setup.call() + assert_failure(outcomes(value)[index], F.PREFLIGHT_TARGET_BUNDLE_INVALID) + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("raw", [None, b"", b"{}", b"null", b"\xff", b"x" * 2049, bytearray(b"{}")]) +def test_request_framing(setup, index, raw): + setup.bundles[index] = replace(setup.bundles[index], request_bytes=raw) + value = setup.call() + assert_failure(outcomes(value)[index], F.PREFLIGHT_TARGET_BUNDLE_INVALID) + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("kind", ["none", "wrong-type", "uninitialized", "swapped", "host", "locator", + "root-pin", "endpoint", "ledger", "credential-target"]) +def test_configuration_preflight_is_structural_and_target_local(setup, index, kind): + config = setup.bundles[index].trusted_configuration + if kind == "none": + config = None + elif kind == "wrong-type": + config = {} + elif kind == "uninitialized": + config = object.__new__(r.Stage2TrustedRuntimeConfiguration) + elif kind == "swapped": + config = setup.bundles[1 - index].trusted_configuration + elif kind == "host": + config = replace(config, known_host_configuration=setup.bundles[1 - index].trusted_configuration.known_host_configuration) + elif kind == "locator": + config = replace(config, credential_configuration=setup.bundles[1 - index].trusted_configuration.credential_configuration) + elif kind == "root-pin": + object.__setattr__(config, "owner_trust_root_expected_file_sha256", SENTINEL) + elif kind == "endpoint": + object.__setattr__(config.known_host_configuration.expected_endpoint, "port", 23) + elif kind == "ledger": + object.__setattr__(config.replay_ledger_configuration, "expected_ledger_instance_id", SENTINEL) + else: + object.__setattr__(config.credential_configuration, "credential_target", "") + setup.bundles[index] = replace(setup.bundles[index], trusted_configuration=config, envelope_bytes=None) + value = setup.call() + expected = F.PREFLIGHT_STARTUP_BINDING_UNAVAILABLE if kind == "none" else F.PREFLIGHT_TARGET_BUNDLE_INVALID + assert_failure(outcomes(value)[index], expected) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("kind", ["missing", "malformed", "expired", "not-yet-valid", "hash", "ref", "id", + "swapped", "target", "credential", "operation", "duplicate-key", "noncanonical"]) +def test_envelope_rejection_uses_existing_stage2_semantics(setup, index, kind): + if kind in ("missing", "malformed", "swapped", "noncanonical", "duplicate-key"): + raw = setup.bundles[index].envelope_bytes + raw = {"missing": None, "malformed": b"{}", "swapped": setup.bundles[1 - index].envelope_bytes, + "noncanonical": raw + b"\n", "duplicate-key": b'{"max_attempts":1,' + raw[1:]}[kind] + setup.bundles[index] = replace(setup.bundles[index], envelope_bytes=raw) + else: + changes = { + "expired": {"issued_at": NOW - 100, "expires_at": NOW}, + "not-yet-valid": {"issued_at": NOW + 1, "expires_at": NOW + 100}, + "hash": {"request_sha256": "0" * 64}, "ref": {"authorization_ref": "authorization.other"}, + "id": {"authorization_id": SENTINEL}, "target": {"target_ref": LABS[1 - index]}, + "credential": {"credential_ref": f"credential.mikrotik.lab0{2 - index}"}, + "operation": {"operation_id": "mikrotik.other"}, + }[kind] + change_envelope(setup, index, **changes) + value = setup.call() + if kind == "swapped": + # A parseable other-target envelope also proves a pair identity collision. + for result in outcomes(value): + assert_failure(result, F.PREFLIGHT_AUTHORIZATION_NOT_DISTINCT) + assert setup.calls == [] and setup.ledger == [] + return + assert_failure(outcomes(value)[index], F.PREFLIGHT_AUTHORIZATION_INVALID) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 1 + + +@pytest.mark.parametrize("field", ["authorization_ref", "authorization_id"]) +def test_duplicate_identity_blocks_both_with_zero_invocations(setup, field): + if field == "authorization_ref": + duplicate = request(1).authorization_ref + change_request(setup, 1, authorization_ref=duplicate) + change_envelope(setup, 1, authorization_ref=duplicate, + request_sha256=hashlib.sha256(setup.bundles[1].request_bytes).hexdigest()) + else: + change_envelope(setup, 1, authorization_id=envelope(request(1), 1).authorization_id) + value = setup.call() + for result in outcomes(value): + assert_failure(result, F.PREFLIGHT_AUTHORIZATION_NOT_DISTINCT) + assert setup.calls == [] and setup.ledger == [] + + +@pytest.mark.parametrize("rejected", [(0,), (1,), (0, 1)]) +@pytest.mark.parametrize("field", ["authorization_id", "authorization_ref"]) +@pytest.mark.parametrize("rejection", ["expired", "not-yet-valid", "hash"]) +def test_p1_parseable_collision_overrides_local_rejection(setup, rejected, field, rejection): + duplicate = getattr(envelope(request(1), 1), field) + change_envelope(setup, 1, **{field: duplicate}) + if field == "authorization_ref": + change_request(setup, 1, authorization_ref=duplicate) + change_envelope(setup, 1, + request_sha256=hashlib.sha256(setup.bundles[1].request_bytes).hexdigest()) + changes = { + "expired": {"issued_at": NOW - 100, "expires_at": NOW}, + "not-yet-valid": {"issued_at": NOW + 1, "expires_at": NOW + 100}, + "hash": {"request_sha256": "0" * 64}, + }[rejection] + for index in rejected: + change_envelope(setup, index, **changes) + # Establish valid test construction using the unchanged Stage-2 validators. + for index, bundle in enumerate(setup.bundles): + parsed = a.parse_stage2_authorization_envelope(bundle.envelope_bytes) + req = c.parse_stage2_vrrp_request_canonical_json(bundle.request_bytes) + binding = r._resolver.build_stage2_fixed_credential_resolver().resolve_for_target( + req.target_ref, req.credential_ref) + if index in rejected: + with pytest.raises(a.Stage2AuthorizationError) as caught: + a.validate_stage2_authorization_binding( + parsed, req, bundle.trusted_configuration.target_registry, binding, now=NOW) + assert caught.value.code is { + "expired": a.Stage2AuthorizationFailure.EXPIRED, + "not-yet-valid": a.Stage2AuthorizationFailure.NOT_YET_VALID, + "hash": a.Stage2AuthorizationFailure.INVALID_BINDING, + }[rejection] + else: + a.validate_stage2_authorization_binding( + parsed, req, bundle.trusted_configuration.target_registry, binding, now=NOW) + value = setup.call() + assert setup.calls == [] and setup.ledger == [] + for result in outcomes(value): + assert_failure(result, F.PREFLIGHT_AUTHORIZATION_NOT_DISTINCT) + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("rejection", ["expired", "not-yet-valid", "hash"]) +def test_p1_distinct_identity_preserves_local_failure_and_policy2(setup, index, rejection): + change_envelope(setup, index, **{ + "expired": {"issued_at": NOW - 100, "expires_at": NOW}, + "not-yet-valid": {"issued_at": NOW + 1, "expires_at": NOW + 100}, + "hash": {"request_sha256": "0" * 64}, + }[rejection]) + value = setup.call() + assert_failure(outcomes(value)[index], F.PREFLIGHT_AUTHORIZATION_INVALID) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 1 + assert setup.ledger == [(LABS[1 - index], "begin"), (LABS[1 - index], "end")] + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("malformed", ["invalid-id", "duplicate-key", "truncated"]) +def test_p1_unparseable_identity_does_not_fabricate_collision(setup, index, malformed): + # Apparent duplicates inside an invalid envelope are not safe pair identity. + raw = setup.bundles[1 - index].envelope_bytes + if malformed == "invalid-id": + data = json.loads(raw) + data["authorization_id"] = "not-a-uuid" + raw = canonical(data) + elif malformed == "duplicate-key": + raw = b'{"max_attempts":1,' + raw[1:] + else: + raw = raw[:-1] + with pytest.raises(a.Stage2AuthorizationError): + a.parse_stage2_authorization_envelope(raw) + setup.bundles[index] = replace(setup.bundles[index], envelope_bytes=raw) + value = setup.call() + assert_failure(outcomes(value)[index], F.PREFLIGHT_AUTHORIZATION_INVALID) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 1 + assert setup.ledger == [(LABS[1 - index], "begin"), (LABS[1 - index], "end")] + + +def test_two_local_failures_have_no_invocations(setup): + setup.bundles[0] = replace(setup.bundles[0], trusted_configuration=None) + setup.bundles[1] = replace(setup.bundles[1], envelope_bytes=b"{}") + value = setup.call() + assert_failure(value.lab1, F.PREFLIGHT_STARTUP_BINDING_UNAVAILABLE) + assert_failure(value.lab2, F.PREFLIGHT_AUTHORIZATION_INVALID) + assert setup.calls == [] + + +@pytest.mark.parametrize("index", [0, 1]) +@pytest.mark.parametrize("kind", ["none", "object", "noncanonical", "duplicate-key", "other-target", + "operation", "policy", "schema", "run", "authorization", "record", "raw-output"]) +def test_candidate_evidence_revalidated_without_substitution(setup, index, kind): + raw = setup.outputs[index] + if kind in ("none", "object", "noncanonical", "duplicate-key", "other-target"): + raw = {"none": None, "object": c.parse_stage2_vrrp_evidence_canonical_json(raw), + "noncanonical": raw + b"\n", "duplicate-key": b'{"attempt_count":1,' + raw[1:], + "other-target": setup.outputs[1 - index]}[kind] + else: + data = json.loads(raw) + field, changed = { + "operation": ("operation_id", "mikrotik.other"), + "policy": ("command_policy_version", "policy.other"), "schema": ("schema_version", "2.0"), + "run": ("run_id", "run.historical"), "authorization": ("authorization_ref", "authorization.historical"), + "record": ("records", [{"raw_stdout": SENTINEL}]), "raw-output": ("raw_stdout", SENTINEL), + }[kind] + data[field] = changed + raw = canonical(data) + setup.outputs[index] = raw + value = setup.call() + assert_failure(outcomes(value)[index], F.CANONICAL_EVIDENCE_VALIDATION_FAILED) + assert outcomes(value)[1 - index].status == "SUCCESS" + assert len(setup.calls) == 2 + assert SENTINEL not in value.to_canonical_bytes().decode() + + +@pytest.mark.parametrize("field,bad", [("query_id", "bad"), ("schema_version", "2.0"), + ("operation_id", "other"), ("execution_order", "PARALLEL")]) +def test_invalid_query_rejected_before_preflight_or_target_results(setup, monkeypatch, field, bad): + object.__setattr__(setup.query, field, bad) + attempted = [] + + def forbidden(*args): + attempted.append("preflight or result") + raise AssertionError(SENTINEL) + + monkeypatch.setattr(m, "_preflight", forbidden) + monkeypatch.setattr(m, "_target_result", forbidden) + with pytest.raises(d.DualLabContractError) as caught: + setup.call() + assert caught.value.args == ("invalid Dual-Lab contract",) + assert caught.value.__context__ is None and caught.value.__cause__ is None + assert attempted == [] and setup.calls == [] + + +@pytest.mark.parametrize("bad", [None, {}, b"{}", b'{"query_id":', "query.synthetic"]) +def test_top_level_requires_exact_accepted_query_type(setup, bad): + setup.query = bad + with pytest.raises(d.DualLabContractError): + setup.call() + assert setup.calls == [] + + +def test_configuration_capture_isolated_before_first_invocation(setup, monkeypatch): + fake = m._invoke_once + second_config = setup.bundles[1].trusted_configuration + + def mutate_original_during_first(req, env, config): + if c.parse_stage2_vrrp_request_canonical_json(req).target_ref == LABS[0]: + object.__setattr__(second_config.known_host_configuration.expected_endpoint, "address", "192.0.2.99") + object.__setattr__(second_config.credential_configuration, "locator_ref", "locator.invalid") + object.__setattr__(second_config.replay_ledger_configuration, "expected_ledger_instance_id", "invalid") + return fake(req, env, config) + + monkeypatch.setattr(m, "_invoke_once", mutate_original_during_first) + value = setup.call() + assert value.lab1.status == value.lab2.status == "SUCCESS" + second = setup.calls[1][2] + assert second.known_host_configuration.expected_endpoint.address == "192.0.2.2" + assert second.credential_configuration.locator_ref == "locator.stage2.mikrotik.lab02.readonly" + assert second.replay_ledger_configuration.expected_ledger_instance_id == "22345678-1234-4234-8234-123456789abc" + + +@pytest.mark.parametrize("stage", ["construction", "serialize", "reparse"]) +def test_final_aggregate_failure_returns_no_partial_output(setup, monkeypatch, stage, capsys): + def fail(*args, **kwargs): + raise ValueError(SENTINEL) + + if stage == "construction": + monkeypatch.setattr(d.DualLabVrrpAggregate, "__post_init__", fail) + elif stage == "serialize": + monkeypatch.setattr(d.DualLabVrrpAggregate, "to_canonical_bytes", fail) + else: + monkeypatch.setattr(d, "parse_aggregate_canonical_json", fail) + with pytest.raises(d.DualLabContractError) as caught: + setup.call() + assert caught.value.__cause__ is None and caught.value.__context__ is None + assert caught.value.args == ("invalid Dual-Lab contract",) + assert len(setup.calls) == 2 + assert capsys.readouterr() == ("", "") + tb = caught.value.__traceback__ + while tb: + if tb.tb_frame.f_globals.get("__name__") == m.__name__: + assert tb.tb_frame.f_code.co_name == "run_dual_lab_vrrp_query" + for name in ("query", "lab1", "lab2", "result"): + assert tb.tb_frame.f_locals[name] is None + tb = tb.tb_next + + +def test_bundle_immutable_redacted_exact_fields_and_no_leakage(setup, capsys): + value = setup.call() + for bundle in setup.bundles: + assert {f.name for f in fields(bundle)} == {"request_bytes", "envelope_bytes", "trusted_configuration"} + assert not hasattr(bundle, "__dict__") + with pytest.raises(FrozenInstanceError): + bundle.trusted_configuration = None + assert repr(bundle) == str(bundle) == "DualLabTargetBundle()" + assert bundle.envelope_bytes.decode() not in repr(bundle) + encoded = value.to_canonical_bytes().decode() + for bundle in setup.bundles: + assert json.loads(bundle.envelope_bytes)["authorization_id"] not in encoded + assert bundle.trusted_configuration.known_host_configuration.expected_path not in encoded + for field in ("raw_stdout", "password", "authorization_id", "signature", "traceback", "model_output", + "health", "healthy", "pair_health", "split_brain", "recommended_action", "remediation", + "observation_timestamp", "aggregate_timestamp", "observation_skew_ms", "max_skew_ms", + "temporal_health", "simultaneous_snapshot", "replay_consumed"): + assert f'"{field}":' not in encoded + assert capsys.readouterr() == ("", "") + + +def test_exact_public_surface_no_executor_and_no_parallel_or_alternate_path(setup): + assert m.__all__ == ("DualLabTargetBundle", "run_dual_lab_vrrp_query") + assert {name for name in vars(m) if not name.startswith("_")} == set(m.__all__) + sig = inspect.signature(m.run_dual_lab_vrrp_query) + assert list(sig.parameters) == ["query", "lab1", "lab2"] + assert all(p.default is inspect.Parameter.empty and p.kind is inspect.Parameter.POSITIONAL_OR_KEYWORD + for p in sig.parameters.values()) + for keyword in ("invoke_function", "executor", "runner", "callback"): + with pytest.raises(TypeError): + m.run_dual_lab_vrrp_query(setup.query, *setup.bundles, **{keyword: lambda: None}) + assert setup.calls == [] + tree = ast.parse(inspect.getsource(m)) + imports = {node.module for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)} + assert imports == {"dataclasses", "validation_framework", + "validation_framework.stage2_trusted_runtime_composition", + "validation_framework.stage2_vrrp_readonly_live_entrypoint"} + assert {alias.name for node in ast.walk(tree) if isinstance(node, ast.Import) + for alias in node.names} == {"time"} + assert not any(isinstance(node, (ast.AsyncFunctionDef, ast.Await, ast.AsyncFor, + ast.For, ast.While, ast.ListComp, ast.GeneratorExp)) + for node in ast.walk(tree)) + calls = [node.func.id for node in ast.walk(tree) if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name)] + assert calls.count("_invoke_once") == 1 + assert not {"open", "exec", "eval", "print", "__import__"} & set(calls) + handoff = next(node for node in tree.body if isinstance(node, ast.ImportFrom) + and node.module.endswith("stage2_vrrp_readonly_live_entrypoint")) + assert any(alias.name == "run_stage2_vrrp_live_once" and alias.asname == "_invoke_once" + for alias in handoff.names) diff --git a/tests/dual_lab/test_dual_lab_vrrp_query_summary.py b/tests/dual_lab/test_dual_lab_vrrp_query_summary.py new file mode 100644 index 0000000..28d46d0 --- /dev/null +++ b/tests/dual_lab/test_dual_lab_vrrp_query_summary.py @@ -0,0 +1,570 @@ +"""DL-03 specification cases 1-42; synthetic data and no live dependencies.""" + +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 pytest + +from validation_framework import dual_lab_vrrp_query_contract as upstream + + +LAB1 = "target.mikrotik.lab01" +LAB2 = "target.mikrotik.lab02" +SCHEMA = "dual-lab-vrrp-query-summary.v1" +MODULE = "validation_framework.dual_lab_vrrp_query_summary" +COMPARE = ("role", "priority", "interval_ms", "version", "running", "disabled", "invalid") +SORT = ("instance_name", "vrid", *COMPARE) +KINDS = { + "RESULT_AVAILABILITY", "VRID_SET_EQUAL", "VRID_SET_DIFFERENT", + "VRID_ONLY_LAB1", "VRID_ONLY_LAB2", "INSTANCE_NAME_SET_EQUAL", + "INSTANCE_NAME_SET_DIFFERENT", "RECORD_COUNT_EQUAL", "RECORD_COUNT_DIFFERENT", + "RECORD_ONLY_LAB1", "RECORD_ONLY_LAB2", "MATCH_KEY_MULTIPLICITY", + "FIELD_EQUAL", "FIELD_DIFFERENT", +} +PROHIBITED = ( + "HEALTHY", "UNHEALTHY", "DEGRADED", "FAILOVER_READY", "HA_READY", + "PRODUCTION_READY", "SPLIT_BRAIN", "MISCONFIGURED", "CORRECT_CONFIGURATION", + "INCORRECT_CONFIGURATION", "ROOT_CAUSE", "REMEDIATION_REQUIRED", + "remediation_command", "configuration_command", "retry_advice", "fallback_advice", + "credential_selection", "endpoint_selection", "authorization_decision", + "command_selection", "execution_instruction", +) +SENSITIVE = ( + "raw_stdout", "credential_ref", "authorization_id", "authorization_ref", + "signature", "private_key", "known_host_raw", "private_path", "replay_data", + "exception", "runtime_configuration", "ssh_session", +) +SENTINEL = "synthetic-rejected-input" + + +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", "role": "BACKUP", "vrid": 88, + "priority": 100, "interval_ms": 1000, "version": 3, + "running": False, "disabled": False, "invalid": False, **changes} + + +def target(ref, records): + if records is None: + return {"status": "FAILURE", "target_ref": ref, "failure_category": "INTERNAL_FAILURE"} + 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 aggregate(left=(), right=()): + return {"schema_version": "dual-lab-vrrp-query.v1", "query_id": "query.synthetic", + "operation_id": "mikrotik.vrrp_status", "execution_order": "LAB1_THEN_LAB2", + "lab1": target(LAB1, left), "lab2": target(LAB2, right)} + + +def obs(kind, left, right, subject=None): + return {"kind": kind, "subject": subject, "lab1_value": left, "lab2_value": right} + + +def key(name="vrrp-synthetic", vrid=88, **extra): + return {"instance_name": name, "vrid": vrid, **extra} + + +def empty_summary(): + return {"schema_version": SCHEMA, "query_id": "query.synthetic", + "operation_id": "mikrotik.vrrp_status", "execution_order": "LAB1_THEN_LAB2", + "lab1": {"status": "SUCCESS", "target_ref": LAB1, "records": []}, + "lab2": {"status": "SUCCESS", "target_ref": LAB2, "records": []}, + "cross_target_observations": [obs("RESULT_AVAILABILITY", True, True), + obs("VRID_SET_EQUAL", [], []), obs("INSTANCE_NAME_SET_EQUAL", [], []), + obs("RECORD_COUNT_EQUAL", 0, 0)]} + + +def one_summary(): + raw = empty_summary() + raw["lab1"]["records"] = [record()] + raw["lab2"]["records"] = [record()] + raw["cross_target_observations"] = [obs("RESULT_AVAILABILITY", True, True), + obs("VRID_SET_EQUAL", [88], [88]), + obs("INSTANCE_NAME_SET_EQUAL", ["vrrp-synthetic"], ["vrrp-synthetic"]), + obs("RECORD_COUNT_EQUAL", 1, 1), + *[obs("FIELD_EQUAL", record()[f], record()[f], key(field=f)) for f in COMPARE]] + return raw + + +@pytest.fixture(scope="session") +def m(): + # Deferred import lets the test-first run validate fixture data independently. + return importlib.import_module(MODULE) + + +def reject(m, function, value): + with pytest.raises(m.DualLabSummaryError) as caught: + function(value) + error = caught.value + assert error.args == ("invalid Dual-Lab summary",) + assert error.__cause__ is None and error.__context__ is None + assert SENTINEL not in str(error) + repr(error) + repr(vars(error)) + + +def project(m, left=(), right=()): + return m.project_dual_lab_vrrp_summary(canonical(aggregate(left, right))) + + +def test_test_first_fixture_contract_is_valid_without_production_module(): + for left, right in product((None, [], [record()], [record(), record(priority=200)]), repeat=2): + raw = aggregate(left, right) + assert upstream.parse_aggregate_canonical_json(canonical(raw)).to_dict() == raw + assert len(one_summary()["cross_target_observations"]) == 11 + assert len(KINDS) == 14 + + +@pytest.mark.parametrize("left,right", product((None, [record()]), repeat=2)) +def test_01_to_04_outcomes_and_failure_absence(m, left, right): + value = project(m, left, right) + raw = value.to_dict() + if left is not None and right is not None: + assert raw == one_summary() + else: + assert raw["cross_target_observations"] == [ + obs("RESULT_AVAILABILITY", left is not None, right is not None)] + for name, ref, records in (("lab1", LAB1, left), ("lab2", LAB2, right)): + expected = target(ref, None) if records is None else { + "status": "SUCCESS", "target_ref": ref, "records": records} + assert raw[name] == expected + assert value.execution_authorized is False + assert value.to_canonical_bytes() == canonical(raw) + assert m.parse_summary_canonical_json(canonical(raw)).to_dict() == raw + + +def test_05_06_empty_and_single_exact_output(m): + assert project(m).to_dict() == empty_summary() + assert project(m, [record()], [record()]).to_dict() == one_summary() + + +def test_07_to_09_multirecord_sort_permutations_metadata_and_identity(m): + records = [record(instance_name="z"), record(instance_name="a", vrid=99), + record(instance_name="a", priority=200), record(instance_name="a"), record()] + expected = sorted(records, key=lambda r: tuple(r[f] for f in SORT)) + baseline = project(m, records, records).to_canonical_bytes() + for order in (records[::-1], records[2:] + records[:2], records): + raw = aggregate(order, records[::-1]) + raw["lab1"]["evidence"].update(run_id="run.other", duration_ms=2, + authorization_ref="authorization.other", raw_output_sha256="b" * 64) + raw = dict(reversed(list(raw.items()))) + result = m.project_dual_lab_vrrp_summary(canonical(raw)) + assert result.lab1.records[0].instance_name == "a" + assert result.to_dict()["lab1"]["records"] == expected + assert result.to_canonical_bytes() == baseline + + +@pytest.mark.parametrize("location", ["top", "success", "failure", "record", "observation", "subject"]) +def test_10_to_13_every_field_required_and_extras_reject(m, location): + raw = one_summary() + if location == "failure": + raw = project(m, None, None).to_dict() + container = {"top": raw, "success": raw["lab1"], "failure": raw["lab1"], + "record": raw["lab2"].get("records", [{}])[0] if location != "failure" else {}, + "observation": raw["cross_target_observations"][-1], + "subject": raw["cross_target_observations"][-1]["subject"]}[location] + for field in list(container): + saved = container.pop(field) + reject(m, m.parse_summary_canonical_json, canonical(raw)) + container[field] = saved + for field in SENSITIVE + ("extra",): + container[field] = SENTINEL + reject(m, m.parse_summary_canonical_json, canonical(raw)) + del container[field] + + +@pytest.mark.parametrize("bad", [None, []]) +def test_11_failure_cannot_have_records(m, bad): + raw = project(m, None, None).to_dict() + raw["lab1"]["records"] = bad + reject(m, m.parse_summary_canonical_json, canonical(raw)) + + +@pytest.mark.parametrize("field,bad", [ + ("instance_name", ""), ("instance_name", " space"), ("instance_name", "a\n"), + ("instance_name", "e\u0301"), ("instance_name", "x" * 129), ("instance_name", 1), + ("role", "HEALTHY"), ("vrid", True), ("vrid", 0), ("vrid", 256), + ("priority", -1), ("priority", 256), ("priority", True), + ("interval_ms", 0), ("interval_ms", 255001), ("version", 1), ("version", True), + ("running", 1), ("disabled", 0), ("invalid", None)]) +def test_12_29_record_domains_in_aggregate_and_summary(m, field, bad): + source = aggregate([record(**{field: bad})], []) + reject(m, m.project_dual_lab_vrrp_summary, canonical(source)) + summary = one_summary() + summary["lab1"]["records"][0][field] = bad + reject(m, m.parse_summary_canonical_json, canonical(summary)) + + +def test_13_envelope_exclusion_and_exact_fields(m): + value = project(m, [record()], [record()]) + raw = value.to_dict() + assert set(raw) == set(empty_summary()) + assert {f.name for f in fields(value)} == set(raw) + assert set(raw["lab1"]) == {"status", "target_ref", "records"} + assert set(raw["lab1"]["records"][0]) == set(SORT) + assert b"authorization.synthetic" not in value.to_canonical_bytes() + assert b"run.synthetic" not in value.to_canonical_bytes() + assert b"raw_output" not in value.to_canonical_bytes() + + +def test_14_to_17_37_38_sets_unmatched_counts_and_order(m): + left = [record(instance_name="a", vrid=1), record(instance_name="a", vrid=1)] + right = [record(instance_name="b", vrid=2)] + rows = project(m, left, right).to_dict()["cross_target_observations"] + assert rows == [obs("RESULT_AVAILABILITY", True, True), + obs("VRID_SET_DIFFERENT", [1], [2]), obs("VRID_ONLY_LAB1", True, False, 1), + obs("VRID_ONLY_LAB2", False, True, 2), + obs("INSTANCE_NAME_SET_DIFFERENT", ["a"], ["b"]), + obs("RECORD_COUNT_DIFFERENT", 2, 1), + obs("RECORD_ONLY_LAB1", 2, 0, key("a", 1)), + obs("RECORD_ONLY_LAB2", 0, 1, key("b", 2))] + same = project(m, left, left).to_dict()["cross_target_observations"] + assert same == [obs("RESULT_AVAILABILITY", True, True), obs("VRID_SET_EQUAL", [1], [1]), + obs("INSTANCE_NAME_SET_EQUAL", ["a"], ["a"]), obs("RECORD_COUNT_EQUAL", 2, 2), + obs("MATCH_KEY_MULTIPLICITY", 2, 2, key("a", 1))] + + +@pytest.mark.parametrize("field,new", [("role", "MASTER"), ("priority", 150), + ("interval_ms", 2000), ("version", 2), ("running", True), + ("disabled", True), ("invalid", True)]) +def test_18_to_21_exact_unique_field_comparisons(m, field, new): + expected = one_summary() + expected["lab2"]["records"][0][field] = new + expected["cross_target_observations"][4 + COMPARE.index(field)] = obs( + "FIELD_DIFFERENT", record()[field], new, key(field=field)) + assert project(m, [record()], [record(**{field: new})]).to_dict() == expected + + +@pytest.mark.parametrize("left,right", product(product((False, True), repeat=3), repeat=2)) +def test_22_all_boolean_comparisons(m, left, right): + names = ("running", "disabled", "invalid") + rows = project(m, [record(**dict(zip(names, left)))], + [record(**dict(zip(names, right)))]).to_dict()["cross_target_observations"][-3:] + assert rows == [obs("FIELD_EQUAL" if a == b else "FIELD_DIFFERENT", a, b, key(field=f)) + for f, a, b in zip(names, left, right)] + + +@pytest.mark.parametrize("different", [record(vrid=89), record(instance_name="vrrp-synthetiC"), + record(instance_name="other", priority=100)]) +def test_23_no_guessed_matches(m, different): + rows = project(m, [record()], [different]).to_dict()["cross_target_observations"] + assert not any(row["kind"].startswith("FIELD_") for row in rows) + assert rows[-2:] == [obs("RECORD_ONLY_LAB1", 1, 0, key()), + obs("RECORD_ONLY_LAB2", 0, 1, key(different["instance_name"], different["vrid"]))] + + +@pytest.mark.parametrize("left_count,right_count", [(2, 1), (1, 2), (2, 2), (3, 2)]) +def test_24_duplicate_multiplicity_never_pairs_or_collapses(m, left_count, right_count): + left = [record()] * left_count + right = [record(priority=200)] * right_count + raw = project(m, left, right).to_dict() + assert len(raw["lab1"]["records"]) == left_count + assert len(raw["lab2"]["records"]) == right_count + assert raw["cross_target_observations"][-1] == obs( + "MATCH_KEY_MULTIPLICITY", left_count, right_count, key()) + assert not any(row["kind"].startswith("FIELD_") for row in raw["cross_target_observations"]) + + +@pytest.mark.parametrize("field,new", [("role", "UNKNOWN"), ("priority", 200), + ("interval_ms", 2000), ("version", 2), ("running", True), + ("disabled", True), ("invalid", True)]) +def test_24_complete_duplicate_sort_tie_breaker(m, field, new): + records = [record(**{field: new}), record(), record()] + expected = sorted(records, key=lambda r: tuple(r[f] for f in SORT)) + assert project(m, records).to_dict()["lab1"]["records"] == expected + assert project(m, records).to_canonical_bytes() == project(m, records[::-1]).to_canonical_bytes() + + +@pytest.mark.parametrize("category", [c.value for c in upstream.FailureCategory]) +def test_25_38_failure_vocabulary_and_no_fabrication(m, category): + raw = aggregate(None, []) + raw["lab1"]["failure_category"] = category + result = m.project_dual_lab_vrrp_summary(canonical(raw)).to_dict() + assert result["lab1"] == raw["lab1"] + assert result["cross_target_observations"] == [obs("RESULT_AVAILABILITY", False, True)] + + +@pytest.mark.parametrize("parser", ["project_dual_lab_vrrp_summary", "parse_summary_canonical_json"]) +def test_26_39_invalid_types_framing_limits_and_forged_objects(m, parser): + function = getattr(m, parser) + valid = canonical(aggregate() if parser.startswith("project") else empty_summary()) + class BytesSubclass(bytes): + pass + class Hostile: + def __bytes__(self): + raise AssertionError("must not coerce") + for bad in (None, {}, [], True, 1, valid.decode(), bytearray(valid), memoryview(valid), + BytesSubclass(valid), Hostile(), object.__new__(upstream.DualLabVrrpAggregate), + 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" * 262145, b'{"x":' + b"[" * 2000 + b"0" + b"]" * 2000 + b"}"): + reject(m, function, bad) + reject(m, function, json.dumps(json.loads(valid), indent=2).encode()) + + +@pytest.mark.parametrize("location", ["aggregate", "target", "evidence", "record"]) +def test_27_extra_aggregate_fields_rejected_upstream(m, location): + raw = aggregate([record()], []) + container = {"aggregate": raw, "target": raw["lab1"], + "evidence": raw["lab1"]["evidence"], "record": raw["lab1"]["evidence"]["records"][0]}[location] + for field in SENSITIVE + ("cross_target_observations",): + if field in container: + continue + container[field] = SENTINEL + reject(m, m.project_dual_lab_vrrp_summary, canonical(raw)) + del container[field] + + +@pytest.mark.parametrize("change", ["swap", "fixed", "evidence", "operation", "order", "schema", "query"]) +def test_28_29_upstream_binding_and_identity_rejections(m, change): + raw = aggregate([record()], [record()]) + if change == "swap": + raw["lab1"], raw["lab2"] = raw["lab2"], raw["lab1"] + elif change == "fixed": + raw["lab1"]["target_ref"] = "target.other" + elif change == "evidence": + raw["lab1"]["evidence"]["target_ref"] = LAB2 + else: + raw[{"operation": "operation_id", "order": "execution_order", + "schema": "schema_version", "query": "query_id"}[change]] = SENTINEL + reject(m, m.project_dual_lab_vrrp_summary, canonical(raw)) + + +@pytest.mark.parametrize("label", PROHIBITED) +def test_30_to_34_prohibited_structures_and_opaque_observed_names(m, label): + raw = one_summary() + raw["cross_target_observations"][0]["kind"] = label + reject(m, m.parse_summary_canonical_json, canonical(raw)) + raw = one_summary() + raw[label] = SENTINEL + reject(m, m.parse_summary_canonical_json, canonical(raw)) + assert project(m, [record(instance_name=label)]).lab1.records[0].instance_name == label + + +def test_33_command_like_name_remains_only_data(m): + name = "synthetic command-like name; do-nothing" + result = project(m, [record(instance_name=name)]).to_dict() + assert result["lab1"]["records"][0]["instance_name"] == name + assert set(result) == set(empty_summary()) + + +def test_35_36_42_static_public_surface_and_inert_import(m, monkeypatch): + expected = {"DualLabVrrpSummary", "DualLabSummaryError", "project_dual_lab_vrrp_summary", + "parse_summary_canonical_json"} + assert set(m.__all__) == expected + assert {n for n in vars(m) if not n.startswith("_")} == expected + for name in ("project_dual_lab_vrrp_summary", "parse_summary_canonical_json"): + assert list(inspect.signature(getattr(m, name)).parameters) == ["raw"] + tree = ast.parse(inspect.getsource(m)) + for node in ast.walk(tree): + assert not isinstance(node, (ast.AsyncFunctionDef, ast.Await, ast.Yield, ast.YieldFrom)) + if isinstance(node, ast.Import): + assert all(a.name in {"json", "dataclasses", "functools"} for a in node.names) + if isinstance(node, ast.ImportFrom): + assert node.module in {"dataclasses", "functools", "validation_framework"} + if node.module == "validation_framework": + assert all(a.name in {"dual_lab_vrrp_query_contract", "stage2_vrrp_readonly_contract"} + for a in node.names) + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name): + assert node.func.id not in {"open", "exec", "eval", "__import__", "print", "input"} + source = canonical(aggregate([record()], [record()])) + summary = canonical(one_summary()) + calls = [] + def forbidden(*args, **kwargs): + calls.append(True) + raise AssertionError("forbidden side effect") + with monkeypatch.context() as guarded: + guarded.setattr(builtins, "open", forbidden) + guarded.setattr(socket, "create_connection", forbidden) + guarded.setattr(socket, "getaddrinfo", forbidden) + guarded.setattr(subprocess, "Popen", forbidden) + before = set(sys.modules) + importlib.reload(m) + for function, data in ((m.project_dual_lab_vrrp_summary, source), + (m.parse_summary_canonical_json, summary)): + value = function(data) + assert value.to_canonical_bytes() == summary + assert value.to_dict() == one_summary() + reject(m, function, b"invalid") + added = set(sys.modules) - before + assert calls == [] + assert not any(any(part in name for part in ("orchestrator", "live_entrypoint", "openai", + "anthropic", "paramiko", "requests", "httpx")) for name in added) + + +@pytest.mark.parametrize("needle", [b'"schema_version":"dual-lab-vrrp-query-summary.v1"', + b'"target_ref":"target.mikrotik.lab01"', b'"priority":100', + b'"kind":"FIELD_EQUAL"', b'"field":"role"']) +def test_39_summary_duplicate_keys_at_each_depth(m, needle): + raw = canonical(one_summary()) + assert needle in raw + reject(m, m.parse_summary_canonical_json, raw.replace(needle, needle + b"," + needle, 1)) + + +@pytest.mark.parametrize("needle", [b'"schema_version":"dual-lab-vrrp-query.v1"', + b'"status":"SUCCESS"', b'"duration_ms":1', b'"priority":100']) +def test_39_aggregate_duplicate_keys_at_each_depth(m, needle): + raw = canonical(aggregate([record()], [])) + reject(m, m.project_dual_lab_vrrp_summary, raw.replace(needle, needle + b"," + needle, 1)) + + +@pytest.mark.parametrize("change", ["missing", "extra", "order", "value", "bool-as-int", "subject", "field"]) +def test_39_derived_rows_recomputed_and_exactly_typed(m, change): + raw = one_summary() + rows = raw["cross_target_observations"] + if change == "missing": + rows.pop() + elif change == "extra": + rows.append(deepcopy(rows[-1])) + elif change == "order": + rows.reverse() + elif change == "value": + rows[-1]["lab1_value"] = True + elif change == "bool-as-int": + rows[0]["lab1_value"] = 1 + elif change == "subject": + rows[-1]["subject"]["vrid"] = 89 + else: + rows[-1]["subject"]["field"] = "vrid" + reject(m, m.parse_summary_canonical_json, canonical(raw)) + + +def test_39_summary_parser_does_not_repair_unsorted_records(m): + raw = project(m, [record(instance_name="a"), record(instance_name="z")]).to_dict() + raw["lab1"]["records"].reverse() + reject(m, m.parse_summary_canonical_json, canonical(raw)) + + +def test_40_frozen_nested_objects_fresh_exports_and_sanitized_repr(m): + raw = aggregate([record()], [record()]) + value = m.project_dual_lab_vrrp_summary(canonical(raw)) + objects = [value, value.lab1, value.lab1.records[0], value.cross_target_observations[-1], + value.cross_target_observations[-1].subject] + for obj in objects: + assert not hasattr(obj, "__dict__") + assert repr(obj) == str(obj) == "" + name = fields(obj)[0].name + with pytest.raises(FrozenInstanceError): + setattr(obj, name, None) + assert type(value.lab1.records) is tuple + assert type(value.cross_target_observations) is tuple + assert type(value.cross_target_observations[1].lab1_value) is tuple + exported = value.to_dict() + exported["lab1"]["records"].clear() + exported["cross_target_observations"][-1]["subject"]["vrid"] = 99 + raw["lab1"]["evidence"]["records"].clear() + assert value.to_dict() == one_summary() + assert value.to_canonical_bytes() == canonical(one_summary()) + + +@pytest.mark.parametrize("where,field,bad", [ + ("top", "query_id", "invalid"), ("target", "target_ref", LAB2), + ("record", "priority", True), ("record", "vrid", 999), + ("row", "lab1_value", True), ("subject", "vrid", 99), + ("top", "cross_target_observations", ()), ("target", "records", [])]) +def test_40_serializers_revalidate_tampering(m, where, field, bad): + value = project(m, [record()], [record()]) + obj = {"top": value, "target": value.lab1, "record": value.lab1.records[0], + "row": value.cross_target_observations[-1], + "subject": value.cross_target_observations[-1].subject}[where] + object.__setattr__(obj, field, bad) + reject(m, lambda _: value.to_dict(), None) + reject(m, lambda _: value.to_canonical_bytes(), None) + assert value.execution_authorized is False + + +@pytest.mark.parametrize("name", ['"' * 128, "\\" * 128, "é" * 64]) +def test_41_maximum_records_name_lengths_and_serialization_bounds(m, name): + records = [record(instance_name=name, vrid=i + 1) for i in range(32)] + value = project(m, records[::-1], records) + assert len(value.lab1.records) == 32 + assert len(value.cross_target_observations) == 228 + assert len(value.to_canonical_bytes()) <= 262144 + assert m.parse_summary_canonical_json(value.to_canonical_bytes()).to_dict() == value.to_dict() + reject(m, m.project_dual_lab_vrrp_summary, canonical(aggregate(records + [record()], []))) + + +def test_41_unicode_sort_and_duplicate_heavy_inputs(m): + records = [record(instance_name=n) for n in ("é", "a", "Ω", "Z")] + assert [r.instance_name for r in project(m, records).lab1.records] == ["Z", "a", "é", "Ω"] + raw = project(m, [record()] * 32, [record()] * 32).to_dict() + assert len(raw["lab1"]["records"]) == 32 + assert raw["cross_target_observations"][-1] == obs("MATCH_KEY_MULTIPLICITY", 32, 32, key()) + + +def test_public_errors_discard_outer_context_and_upstream_failure(m, monkeypatch, capsys): + try: + raise RuntimeError(SENTINEL) + except RuntimeError: + reject(m, m.project_dual_lab_vrrp_summary, b"bad") + def broken(*args, **kwargs): + raise RuntimeError(SENTINEL) + monkeypatch.setattr(upstream, "parse_aggregate_canonical_json", broken) + reject(m, m.project_dual_lab_vrrp_summary, canonical(aggregate())) + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("field,bad", [("schema_version", "dual-lab-vrrp-query.v1"), + ("query_id", "invalid"), ("query_id", "query." + "a" * 155), + ("operation_id", "other.operation"), ("execution_order", "LAB2_THEN_LAB1")]) +def test_summary_identity_has_its_own_strict_boundary(m, field, bad): + raw = empty_summary() + raw[field] = bad + reject(m, m.parse_summary_canonical_json, canonical(raw)) + + +@pytest.mark.parametrize("change", ["swap", "target", "status", "failure_category"]) +def test_summary_target_binding_and_tag_validation(m, change): + raw = project(m, None, None).to_dict() + if change == "swap": + raw["lab1"], raw["lab2"] = raw["lab2"], raw["lab1"] + else: + raw["lab1"][{"target": "target_ref", "status": "status", + "failure_category": "failure_category"}[change]] = SENTINEL + reject(m, m.parse_summary_canonical_json, canonical(raw)) + + +def test_uninitialized_subclassed_and_callback_bearing_dtos_fail_closed(m): + calls = [] + class Forged(m.DualLabVrrpSummary): + def to_dict(self): + calls.append(True) + return empty_summary() + subclass = object.__new__(Forged) + reject(m, lambda _: m.DualLabVrrpSummary.to_dict(subclass), None) + reject(m, lambda _: m.DualLabVrrpSummary.to_canonical_bytes(subclass), None) + assert calls == [] + missing = object.__new__(m.DualLabVrrpSummary) + reject(m, lambda _: missing.to_dict(), None) + reject(m, lambda _: missing.to_canonical_bytes(), None) + reject(m, lambda _: m.DualLabVrrpSummary(), None) + value = project(m) + object.__setattr__(value, "lab1", subclass) + reject(m, lambda _: value.to_dict(), None) + assert calls == [] + + +def test_summary_parser_does_not_invoke_projection(m, monkeypatch): + def forbidden(*args, **kwargs): + pytest.fail("summary parsing must not invoke projection or aggregate parsing") + monkeypatch.setattr(m, "project_dual_lab_vrrp_summary", forbidden) + monkeypatch.setattr(upstream, "parse_aggregate_canonical_json", forbidden) + assert m.parse_summary_canonical_json(canonical(one_summary())).to_dict() == one_summary() diff --git a/tests/test_day147_ai_assistance_deferred_risk_register.py b/tests/test_day147_ai_assistance_deferred_risk_register.py index 6b8c192..f7a4852 100644 --- a/tests/test_day147_ai_assistance_deferred_risk_register.py +++ b/tests/test_day147_ai_assistance_deferred_risk_register.py @@ -66,7 +66,12 @@ def test_day147_risk_register_covers_required_categories_with_no_unsafe_flags(): assert risk["unsafe_flags"][field] is False -def test_day147_cli_does_not_execute_provider_network_or_prior_day_paths(monkeypatch, capsys): +def test_day147_cli_does_not_execute_provider_network_or_prior_day_paths(monkeypatch, capsys, tmp_path): + json_output = tmp_path / "day147.json" + html_output = tmp_path / "day147.html" + monkeypatch.setattr(day147, "REPORT_JSON", json_output) + monkeypatch.setattr(day147, "REPORT_HTML", html_output) + def fail_subprocess(*args, **kwargs): raise AssertionError("Day147 must not execute subprocess") @@ -102,6 +107,8 @@ def fail_day146(*args, **kwargs): assert "live_network_enabled: false" in output assert "secrets_required: false" in output assert "[PASS] AI_ASSISTANCE_DEFERRED_RISK_REGISTER_READY" in output + assert json_output.is_file() + assert html_output.is_file() def test_day147_negative_validation_blocks_unsafe_flags_and_gate_mutation(): @@ -229,3 +236,40 @@ def test_day147_docs_preserve_required_deferred_risk_boundaries(): assert "KEEP_AI_ASSISTANCE_DEFERRED_AND_NEXT_PHASE_FALSE" in doc for category in day147.REQUIRED_CATEGORIES: assert category.rstrip(".") in doc + + +def test_day147_report_outputs_are_lf_and_preserve_semantics(tmp_path): + report = day147.build_day147_ai_assistance_deferred_risk_register(PROJECT_ROOT) + expected = copy.deepcopy(report) + + json_path, html_path = day147.write_day147_ai_assistance_deferred_risk_register_reports( + tmp_path, report + ) + json_bytes = json_path.read_bytes() + html_bytes = html_path.read_bytes() + + for data in (json_bytes, html_bytes): + assert b"\n" in data + assert b"\r" not in data + assert json.loads(json_bytes) == expected + assert report == expected + assert expected["overall_status"] == "PASS" + assert expected["status"] == "AI_ASSISTANCE_DEFERRED_RISK_REGISTER_READY" + assert expected["next_phase_allowed"] is False + for field in day147.REQUIRED_TRUE_FIELDS: + assert expected[field] is True + for field in day147.REQUIRED_FALSE_FIELDS: + assert expected[field] is False + + +def test_day147_repeated_report_generation_is_byte_identical(tmp_path): + report = day147.build_day147_ai_assistance_deferred_risk_register(PROJECT_ROOT) + first_json, first_html = day147.write_day147_ai_assistance_deferred_risk_register_reports( + tmp_path / "first", report + ) + second_json, second_html = day147.write_day147_ai_assistance_deferred_risk_register_reports( + tmp_path / "second", report + ) + + assert first_json.read_bytes() == second_json.read_bytes() + assert first_html.read_bytes() == second_html.read_bytes() diff --git a/validation_framework/dual_lab_vrrp_query_contract.py b/validation_framework/dual_lab_vrrp_query_contract.py new file mode 100644 index 0000000..e912893 --- /dev/null +++ b/validation_framework/dual_lab_vrrp_query_contract.py @@ -0,0 +1,332 @@ +"""DL-01 inert Dual-Lab VRRP contracts; validity never authorizes execution. + +The minimal query carries schema, caller-supplied query identity, fixed operation, +and fixed order. Its target scope is always Lab1 and Lab2. The aggregate adds +exactly those two tagged results. No runtime bundles or authority are accepted. + +The five Dual-Lab failure identifiers were specified by the Owner clarification; +the other six preserve S2-RO-11 spellings without importing its live entrypoint. +""" + +from dataclasses import dataclass as _dataclass +from enum import Enum as _Enum +from functools import wraps as _wraps +import json as _json +import re as _re + +from validation_framework import stage2_vrrp_readonly_contract as _stage2 + + +_SCHEMA = "dual-lab-vrrp-query.v1" +_OPERATION = _stage2.VRRP_OBSERVATION_OPERATION_ID +_ORDER = "LAB1_THEN_LAB2" +_LAB1 = "target.mikrotik.lab01" +_LAB2 = "target.mikrotik.lab02" +_QUERY_FIELDS = frozenset({"schema_version", "query_id", "operation_id", "execution_order"}) +_AGGREGATE_FIELDS = _QUERY_FIELDS | {"lab1", "lab2"} +_SUCCESS_FIELDS = frozenset({"status", "target_ref", "evidence"}) +_FAILURE_FIELDS = frozenset({"status", "target_ref", "failure_category"}) +# Same bounded lowercase dotted logical-reference grammar as S2-RO-01. +_QUERY_ID = _re.compile(r"query\.[a-z][a-z0-9_-]*(?:\.[a-z][a-z0-9_-]*)*") +_MAX_QUERY_BYTES = _stage2.MAX_REQUEST_CANONICAL_BYTES +_MAX_RESULT_BYTES = _stage2.MAX_EVIDENCE_CANONICAL_BYTES + 1_024 +_MAX_AGGREGATE_BYTES = 2 * _MAX_RESULT_BYTES + _MAX_QUERY_BYTES + + +class FailureCategory(_Enum): + """Exact Owner-approved target failure vocabulary; no exception details. + + PREFLIGHT_TARGET_BUNDLE_INVALID covers non-authorization bundle structure + and fixed-target bindings, excluding a missing persistent startup binding. + PREFLIGHT_AUTHORIZATION_INVALID covers unusable authorization before entry. + PREFLIGHT_AUTHORIZATION_NOT_DISTINCT covers shared/colliding authorization + references or IDs; neither target may rely on the duplicated identity. + PREFLIGHT_STARTUP_BINDING_UNAVAILABLE covers unavailable accepted startup + reconstruction, never discovery or substitution of another configuration. + CANONICAL_EVIDENCE_VALIDATION_FAILED covers rejected candidate evidence. + These are data labels only: this module performs none of those operations. + """ + + PREFLIGHT_TARGET_BUNDLE_INVALID = "PREFLIGHT_TARGET_BUNDLE_INVALID" + PREFLIGHT_AUTHORIZATION_INVALID = "PREFLIGHT_AUTHORIZATION_INVALID" + PREFLIGHT_AUTHORIZATION_NOT_DISTINCT = "PREFLIGHT_AUTHORIZATION_NOT_DISTINCT" + PREFLIGHT_STARTUP_BINDING_UNAVAILABLE = "PREFLIGHT_STARTUP_BINDING_UNAVAILABLE" + CANONICAL_EVIDENCE_VALIDATION_FAILED = "CANONICAL_EVIDENCE_VALIDATION_FAILED" + INVALID_REQUEST_INPUT = "INVALID_REQUEST_INPUT" + INVALID_AUTHORIZATION_INPUT = "INVALID_AUTHORIZATION_INPUT" + INVALID_TRUSTED_CONFIGURATION = "INVALID_TRUSTED_CONFIGURATION" + TRUSTED_RUNTIME_FAILED = "TRUSTED_RUNTIME_FAILED" + OUTPUT_RENDER_FAILED = "OUTPUT_RENDER_FAILED" + INTERNAL_FAILURE = "INTERNAL_FAILURE" + + +class DualLabContractError(ValueError): + """Rejected contract input, never a TargetResult or an aggregate.""" + + def __init__(self): + super().__init__("invalid Dual-Lab contract") + + +def _reject(): + try: + raise DualLabContractError() from None + except DualLabContractError as error: + error.__context__ = None + raise + + +def _checked(function): + """Discard child errors before raising the fixed public rejection.""" + @_wraps(function) + def checked(*args, **kwargs): + try: + return function(*args, **kwargs) + except Exception: + pass + args = kwargs = None + _reject() + return checked + + +def _exact(value, expected): + if type(value) is not str or value != expected: + _reject() + + +def _identity(value): + _exact(value.schema_version, _SCHEMA) + _exact(value.operation_id, _OPERATION) + _exact(value.execution_order, _ORDER) + if (type(value.query_id) is not str + or not 1 <= len(value.query_id) <= _stage2.MAX_REFERENCE_LENGTH + or _QUERY_ID.fullmatch(value.query_id) is None): + _reject() + return {name: getattr(value, name) for name in _QUERY_FIELDS} + + +def _target(value): + if type(value) is not str or value not in (_LAB1, _LAB2): + _reject() + + +def _evidence(value, target_ref): + if type(value) is not _stage2.Stage2VrrpObservationEvidence: + _reject() + # Revalidate even exact-type objects altered after construction. Reparse + # also detaches mutable source data without defining a second evidence schema. + parsed = _stage2.parse_stage2_vrrp_evidence_canonical_json(value.to_canonical_bytes()) + if parsed.target_ref != target_ref: + _reject() + return parsed + + +class _Canonical: + __slots__ = () + + def __repr__(self): + return f"{type(self).__name__}()" + + __str__ = __repr__ + + @property + def execution_authorized(self): + return False + + @_checked + def to_canonical_bytes(self): + if type(self) is DualLabVrrpQuery: + maximum, parser = _MAX_QUERY_BYTES, parse_query_canonical_json + elif type(self) is DualLabVrrpAggregate: + maximum, parser = _MAX_AGGREGATE_BYTES, parse_aggregate_canonical_json + elif type(self) in (TargetSuccess, TargetFailure): + maximum, parser = _MAX_RESULT_BYTES, parse_target_result_canonical_json + else: + _reject() + encoded = _encode(self.to_dict(), maximum) + parser(encoded) + return encoded + + +@_dataclass(frozen=True, slots=True, repr=False) +class DualLabVrrpQuery(_Canonical): + """Logical query for the fixed pair, with no invocation or authority data.""" + + schema_version: str + query_id: str + operation_id: str + execution_order: str + + @_checked + def __post_init__(self): + _identity(self) + + @_checked + def to_dict(self): + return _identity(self) + + +@_dataclass(frozen=True, slots=True, repr=False) +class TargetSuccess(_Canonical): + """Exactly one fixed target's canonical S2-RO-01 evidence.""" + + status: str + target_ref: str + evidence: _stage2.Stage2VrrpObservationEvidence + + @_checked + def __post_init__(self): + _exact(self.status, "SUCCESS") + _target(self.target_ref) + object.__setattr__(self, "evidence", _evidence(self.evidence, self.target_ref)) + + @_checked + def to_dict(self): + _exact(self.status, "SUCCESS") + _target(self.target_ref) + return {"status": self.status, "target_ref": self.target_ref, + "evidence": _evidence(self.evidence, self.target_ref).to_dict()} + + +@_dataclass(frozen=True, slots=True, repr=False) +class TargetFailure(_Canonical): + """A bounded target outcome only, never a top-level validation error.""" + + status: str + target_ref: str + failure_category: FailureCategory + + @_checked + def __post_init__(self): + _exact(self.status, "FAILURE") + _target(self.target_ref) + if type(self.failure_category) is not FailureCategory: + _reject() + + @_checked + def to_dict(self): + self.__post_init__() + return {"status": self.status, "target_ref": self.target_ref, + "failure_category": self.failure_category.value} + + +TargetResult = TargetSuccess | TargetFailure + + +@_dataclass(frozen=True, slots=True, repr=False) +class DualLabVrrpAggregate(_Canonical): + """Two independent observations; order metadata is not an execution path.""" + + schema_version: str + query_id: str + operation_id: str + execution_order: str + lab1: TargetResult + lab2: TargetResult + + @_checked + def __post_init__(self): + _identity(self) + _result_dict(self.lab1, _LAB1) + _result_dict(self.lab2, _LAB2) + + @_checked + def to_dict(self): + return {**_identity(self), "lab1": _result_dict(self.lab1, _LAB1), + "lab2": _result_dict(self.lab2, _LAB2)} + + +def _result_dict(value, target_ref): + if type(value) not in (TargetSuccess, TargetFailure): + _reject() + _exact(value.target_ref, target_ref) + return value.to_dict() + + +def _fields(raw, expected): + if type(raw) is not dict or raw.keys() != expected: + _reject() + + +@_checked +def parse_query(raw: object) -> DualLabVrrpQuery: + """Parse exactly four query fields; the target pair is implicit and fixed.""" + _fields(raw, _QUERY_FIELDS) + return DualLabVrrpQuery(**raw) + + +@_checked +def parse_target_result(raw: object) -> TargetResult: + """Parse one exact tagged variant; malformed evidence is rejected.""" + if type(raw) is not dict or type(raw.get("status")) is not str: + _reject() + if raw["status"] == "SUCCESS": + _fields(raw, _SUCCESS_FIELDS) + return TargetSuccess(raw["status"], raw["target_ref"], + _stage2.parse_stage2_vrrp_observation_evidence(raw["evidence"])) + if raw["status"] == "FAILURE": + _fields(raw, _FAILURE_FIELDS) + if type(raw["failure_category"]) is not str: + _reject() + return TargetFailure(raw["status"], raw["target_ref"], FailureCategory(raw["failure_category"])) + _reject() + + +@_checked +def parse_aggregate(raw: object) -> DualLabVrrpAggregate: + """Reject invalid aggregate input without manufacturing target failures.""" + _fields(raw, _AGGREGATE_FIELDS) + query = parse_query({key: raw[key] for key in _QUERY_FIELDS}) + return DualLabVrrpAggregate(**query.to_dict(), lab1=parse_target_result(raw["lab1"]), + lab2=parse_target_result(raw["lab2"])) + + +def _encode(raw, maximum): + encoded = _json.dumps(raw, allow_nan=False, ensure_ascii=False, + separators=(",", ":"), sort_keys=True).encode("utf-8", errors="strict") + if not 1 <= len(encoded) <= maximum: + _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, maximum): + if (type(raw) is not bytes or not 1 <= len(raw) <= maximum + 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 type(parsed) is not dict or _encode(parsed, maximum) != raw: + _reject() + return parsed + + +@_checked +def parse_query_canonical_json(raw: bytes) -> DualLabVrrpQuery: + return parse_query(_decode(raw, _MAX_QUERY_BYTES)) + + +@_checked +def parse_target_result_canonical_json(raw: bytes) -> TargetResult: + return parse_target_result(_decode(raw, _MAX_RESULT_BYTES)) + + +@_checked +def parse_aggregate_canonical_json(raw: bytes) -> DualLabVrrpAggregate: + return parse_aggregate(_decode(raw, _MAX_AGGREGATE_BYTES)) + + +__all__ = ( + "FailureCategory", "DualLabContractError", "DualLabVrrpQuery", + "TargetSuccess", "TargetFailure", "TargetResult", "DualLabVrrpAggregate", + "parse_query", "parse_target_result", "parse_aggregate", + "parse_query_canonical_json", "parse_target_result_canonical_json", + "parse_aggregate_canonical_json", +) diff --git a/validation_framework/dual_lab_vrrp_query_orchestrator.py b/validation_framework/dual_lab_vrrp_query_orchestrator.py new file mode 100644 index 0000000..709d37c --- /dev/null +++ b/validation_framework/dual_lab_vrrp_query_orchestrator.py @@ -0,0 +1,168 @@ +"""DL-02 fixed synchronous orchestration; configuration is not live authority. + +Trusted callers supply the accepted persistent configurations. None represents +an unavailable startup binding; this module cannot attest provider provenance. +Both bundles are preflighted before any call. A local rejection skips only that +target; colliding parseable authorization identities reject the pair. Lab1's outcome +is finalized before Lab2 is invoked. Ordering does not imply simultaneous data. +""" + +from dataclasses import dataclass as _dataclass +import time as _time + +from validation_framework import dual_lab_vrrp_query_contract as _dual +from validation_framework import stage2_vrrp_readonly_contract as _stage2 +from validation_framework import stage2_authorization_envelope_ledger as _authorization +from validation_framework import stage2_mikrotik_credential_resolver as _credentials +from validation_framework.stage2_trusted_runtime_composition import ( + Stage2TrustedRuntimeConfiguration as _Configuration, + _configuration_values, +) +from validation_framework.stage2_vrrp_readonly_live_entrypoint import ( + Stage2VrrpLiveEntrypointError as _EntrypointError, + Stage2VrrpLiveEntrypointFailure as _EntrypointFailure, + run_stage2_vrrp_live_once as _invoke_once, +) + + +_LAB1 = "target.mikrotik.lab01" +_LAB2 = "target.mikrotik.lab02" +_F = _dual.FailureCategory + + +@_dataclass(frozen=True, slots=True, repr=False) +class DualLabTargetBundle: + """Three inert inputs; malformed inputs become target-local preflight errors.""" + + request_bytes: bytes + envelope_bytes: bytes + trusted_configuration: _Configuration | None + + def __repr__(self): + return "DualLabTargetBundle()" + + __str__ = __repr__ + + +def _utc_now(): + return _time.time_ns() // 1_000_000_000 + + +def _preflight(bundle, target_ref): + """Return eligible inputs, local failure, and independently retained identity. + + Bundle/request errors precede configuration errors, which precede envelope + errors. None has its dedicated configuration error. Once an envelope safely + parses, retain it for pair comparison even if local binding/time checks fail. + This identity grants no execution eligibility and acquires no authority. + """ + stage = _F.PREFLIGHT_TARGET_BUNDLE_INVALID + envelope = None + try: + if type(bundle) is not DualLabTargetBundle: + return None, stage, envelope + request_bytes, envelope_bytes = bundle.request_bytes, bundle.envelope_bytes + request = _stage2.parse_stage2_vrrp_request_canonical_json(request_bytes) + if request.target_ref != target_ref: + return None, stage, envelope + binding = _credentials.build_stage2_fixed_credential_resolver().resolve_for_target( + request.target_ref, request.credential_ref) + if bundle.trusted_configuration is None: + return None, _F.PREFLIGHT_STARTUP_BINDING_UNAVAILABLE, envelope + # Reuse S2-RO-10's pure capture/validation, including every nested record. + # It performs no asset acquisition and avoids a second configuration schema. + configuration = _Configuration(*_configuration_values(bundle.trusted_configuration)) + endpoint = configuration.target_registry.lookup(target_ref) + if (configuration.known_host_configuration.expected_endpoint != endpoint + or configuration.credential_configuration.locator_ref != binding.locator_ref): + return None, stage, envelope + stage = _F.PREFLIGHT_AUTHORIZATION_INVALID + envelope = _authorization.parse_stage2_authorization_envelope(envelope_bytes) + _authorization.validate_stage2_authorization_binding( + envelope, request, configuration.target_registry, binding, now=_utc_now()) + return (request_bytes, envelope_bytes, configuration, request, envelope), None, envelope + except Exception: + pass + return None, stage, envelope + + +def _failure(target_ref, category): + return _dual.TargetFailure("FAILURE", target_ref, category) + + +def _target_result(target_ref, captured, preflight_failure): + if preflight_failure is not None: + return _failure(target_ref, preflight_failure) + request_bytes, envelope_bytes, configuration, request, _ = captured + candidate = None + failure = None + try: + candidate = _invoke_once(request_bytes, envelope_bytes, configuration) + except _EntrypointError as error: + failure = _F.INTERNAL_FAILURE + if type(error) is _EntrypointError and type(getattr(error, "code", None)) is _EntrypointFailure: + failure = _F(error.code.value) + except Exception: + failure = _F.INTERNAL_FAILURE + if failure is not None: + return _failure(target_ref, failure) + try: + evidence = _stage2.parse_stage2_vrrp_evidence_canonical_json(candidate) + evidence = _stage2.parse_stage2_vrrp_evidence_canonical_json(evidence.to_canonical_bytes()) + if (evidence.target_ref != target_ref + or evidence.operation_id != request.operation_id + or evidence.command_policy_version != _stage2.VRRP_COMMAND_POLICY_VERSION + or evidence.run_id != request.run_id + or evidence.authorization_ref != request.authorization_ref): + return _failure(target_ref, _F.CANONICAL_EVIDENCE_VALIDATION_FAILED) + return _dual.TargetSuccess("SUCCESS", target_ref, evidence) + except Exception: + pass + return _failure(target_ref, _F.CANONICAL_EVIDENCE_VALIDATION_FAILED) + + +def _run(query, lab1, lab2): + try: + if type(query) is not _dual.DualLabVrrpQuery: + return None + query = _dual.parse_query_canonical_json(query.to_canonical_bytes()) + first, first_failure, first_identity = _preflight(lab1, _LAB1) + second, second_failure, second_identity = _preflight(lab2, _LAB2) + if first_identity is not None and second_identity is not None: + if (first_identity.authorization_ref == second_identity.authorization_ref + or first_identity.authorization_id == second_identity.authorization_id): + first_failure = second_failure = _F.PREFLIGHT_AUTHORIZATION_NOT_DISTINCT + first_result = _target_result(_LAB1, first, first_failure) + second_result = _target_result(_LAB2, second, second_failure) + aggregate = _dual.DualLabVrrpAggregate( + **query.to_dict(), lab1=first_result, lab2=second_result) + return _dual.parse_aggregate_canonical_json(aggregate.to_canonical_bytes()) + except Exception: + # No child traceback, input data or partial aggregate crosses this boundary. + pass + return None + + +def run_dual_lab_vrrp_query( + query: _dual.DualLabVrrpQuery, + lab1: DualLabTargetBundle, + lab2: DualLabTargetBundle, +) -> _dual.DualLabVrrpAggregate: + """Return the canonical fixed-pair aggregate or a sanitized contract error. + + At most one synchronous S2-RO-11 invocation per eligible target, without retry. + The private binding is a test seam, never a public execution override. Input + reference release is not memory zeroization or isolation from trusted callers. + """ + result = _run(query, lab1, lab2) + query = lab1 = lab2 = None + if result is None: + try: + raise _dual.DualLabContractError() from None + except _dual.DualLabContractError as error: + error.__context__ = None + raise + return result + + +__all__ = ("DualLabTargetBundle", "run_dual_lab_vrrp_query") diff --git a/validation_framework/dual_lab_vrrp_query_summary.py b/validation_framework/dual_lab_vrrp_query_summary.py new file mode 100644 index 0000000..4e19da2 --- /dev/null +++ b/validation_framework/dual_lab_vrrp_query_summary.py @@ -0,0 +1,335 @@ +"""DL-03 inert facts-only projection; data validity grants no execution authority. + +The aggregate boundary is owned by DL-01. Summary parsing independently checks +the projected schema and recomputes every observation from its target records. +""" + +from dataclasses import dataclass as _dataclass, fields as _members +from functools import wraps as _wraps +import json as _json + +from validation_framework import dual_lab_vrrp_query_contract as _upstream +from validation_framework import stage2_vrrp_readonly_contract as _stage2 + + +_SCHEMA = "dual-lab-vrrp-query-summary.v1" +_IDENTITY = ("schema_version", "query_id", "operation_id", "execution_order") +_TOP = frozenset((*_IDENTITY, "lab1", "lab2", "cross_target_observations")) +_COMPARE = ("role", "priority", "interval_ms", "version", "running", "disabled", "invalid") +_SORT = ("instance_name", "vrid", *_COMPARE) +_LAB1 = "target.mikrotik.lab01" +_LAB2 = "target.mikrotik.lab02" +_MAX_BYTES = 262_144 +_MAX_OBSERVATIONS = 356 + + +class DualLabSummaryError(ValueError): + """Fixed public rejection without rejected data or child exceptions.""" + + def __init__(self): + super().__init__("invalid Dual-Lab summary") + + +def _reject(): + try: + raise DualLabSummaryError() from None + except DualLabSummaryError 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__ + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Record(_Inert): + instance_name: str + role: str + vrid: int + priority: int + interval_ms: int + version: int + running: bool + disabled: bool + invalid: bool + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Success(_Inert): + status: str + target_ref: str + records: tuple + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Failure(_Inert): + status: str + target_ref: str + failure_category: str + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Key(_Inert): + instance_name: str + vrid: int + + +@_dataclass(frozen=True, slots=True, repr=False) +class _FieldKey(_Inert): + instance_name: str + vrid: int + field: str + + +@_dataclass(frozen=True, slots=True, repr=False) +class _Observation(_Inert): + kind: str + subject: _Key | _FieldKey | int | None + lab1_value: str | int | bool | tuple + lab2_value: str | int | bool | tuple + + +@_dataclass(frozen=True, slots=True, repr=False, init=False) +class DualLabVrrpSummary(_Inert): + """Factory-created immutable DTO; serialization revalidates nested data.""" + + schema_version: str + query_id: str + operation_id: str + execution_order: str + lab1: _Success | _Failure + lab2: _Success | _Failure + cross_target_observations: tuple + + def __init__(self, *args, **kwargs): + _reject() + + @property + def execution_authorized(self): + return False + + @_checked + def to_dict(self) -> dict[str, object]: + if type(self) is not DualLabVrrpSummary: + _reject() + raw = _plain(self) + _validate(raw) + _encode(raw) + return raw + + @_checked + def to_canonical_bytes(self) -> bytes: + return _encode(DualLabVrrpSummary.to_dict(self)) + + +_VALUE_TYPES = (DualLabVrrpSummary, _Success, _Failure, _Record, _Key, _FieldKey, _Observation) + + +def _plain(value): + """Export only exact inert values, never arbitrary mappings or methods.""" + 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) in _VALUE_TYPES: + return {field.name: _plain(getattr(value, field.name)) for field in _members(value)} + _reject() + + +def _exact_fields(raw, expected): + if type(raw) is not dict or raw.keys() != set(expected): + _reject() + + +def _sort_key(record): + return tuple(record[field] for field in _SORT) + + +def _validate_target(raw, target): + if type(raw) is not dict or type(raw.get("status")) is not str: + _reject() + if type(raw.get("target_ref")) is not str or raw["target_ref"] != target: + _reject() + if raw["status"] == "FAILURE": + # The projected failure is exactly the existing inert DL-01 variant. + _upstream.parse_target_result(raw) + return + if raw["status"] != "SUCCESS": + _reject() + _exact_fields(raw, ("status", "target_ref", "records")) + records = raw["records"] + if type(records) is not list or len(records) > _stage2.MAX_VRRP_RECORDS: + _reject() + for record in records: + _exact_fields(record, _SORT) + _stage2.NormalizedVrrpRecord(**record) + if records != sorted(records, key=_sort_key): + _reject() + + +def _row(kind, left, right, subject=None): + return {"kind": kind, "subject": subject, "lab1_value": left, "lab2_value": right} + + +def _equality(prefix, left, right, subject=None): + return _row(prefix + ("_EQUAL" if left == right else "_DIFFERENT"), left, right, subject) + + +def _groups(records): + groups = {} + for record in records: + key = (record["instance_name"], record["vrid"]) + groups.setdefault(key, []).append(record) + return groups + + +def _subject(key): + return {"instance_name": key[0], "vrid": key[1]} + + +def _derive(lab1, lab2): + """Exact row order; duplicate keys carry counts and never imply pairing.""" + available1, available2 = lab1["status"] == "SUCCESS", lab2["status"] == "SUCCESS" + rows = [_row("RESULT_AVAILABILITY", available1, available2)] + if not (available1 and available2): + return rows + left, right = lab1["records"], lab2["records"] + vrids1, vrids2 = ({r["vrid"] for r in records} for records in (left, right)) + rows.append(_equality("VRID_SET", sorted(vrids1), sorted(vrids2))) + rows.extend(_row("VRID_ONLY_LAB1", True, False, v) for v in sorted(vrids1 - vrids2)) + rows.extend(_row("VRID_ONLY_LAB2", False, True, v) for v in sorted(vrids2 - vrids1)) + names1, names2 = (sorted({r["instance_name"] for r in records}) for records in (left, right)) + rows.append(_equality("INSTANCE_NAME_SET", names1, names2)) + rows.append(_equality("RECORD_COUNT", len(left), len(right))) + groups1, groups2 = _groups(left), _groups(right) + keys1, keys2 = set(groups1), set(groups2) + rows.extend(_row("RECORD_ONLY_LAB1", len(groups1[k]), 0, _subject(k)) + for k in sorted(keys1 - keys2)) + rows.extend(_row("RECORD_ONLY_LAB2", 0, len(groups2[k]), _subject(k)) + for k in sorted(keys2 - keys1)) + common = sorted(keys1 & keys2) + for key in common: + a, b = len(groups1[key]), len(groups2[key]) + if a > 1 or b > 1: + rows.append(_row("MATCH_KEY_MULTIPLICITY", a, b, _subject(key))) + for key in common: + if len(groups1[key]) == len(groups2[key]) == 1: + for field in _COMPARE: + rows.append(_equality("FIELD", groups1[key][0][field], groups2[key][0][field], + {**_subject(key), "field": field})) + return rows + + +def _validate(raw): + _exact_fields(raw, _TOP) + if type(raw["schema_version"]) is not str or raw["schema_version"] != _SCHEMA: + _reject() + # Query identity retains the existing DL-01 grammar and fixed operation/order. + identity = {name: raw[name] for name in _IDENTITY} + identity["schema_version"] = "dual-lab-vrrp-query.v1" + _upstream.parse_query(identity) + _validate_target(raw["lab1"], _LAB1) + _validate_target(raw["lab2"], _LAB2) + rows = raw["cross_target_observations"] + if type(rows) is not list or len(rows) > _MAX_OBSERVATIONS: + _reject() + # Byte equality also rejects Python's True == 1 and False == 0 aliases. + # The recomputed rows close all kind/subject/value domains and field sets. + if _encode(rows) != _encode(_derive(raw["lab1"], raw["lab2"])): + _reject() + + +def _freeze_target(raw): + if raw["status"] == "FAILURE": + return _Failure(**raw) + return _Success(raw["status"], raw["target_ref"], tuple(_Record(**r) for r in raw["records"])) + + +def _freeze_row(raw): + subject = raw["subject"] + if type(subject) is dict: + subject = _FieldKey(**subject) if "field" in subject else _Key(**subject) + values = [tuple(raw[name]) if type(raw[name]) is list else raw[name] + for name in ("lab1_value", "lab2_value")] + return _Observation(raw["kind"], subject, *values) + + +def _build(raw): + _validate(raw) + _encode(raw) + value = object.__new__(DualLabVrrpSummary) + for name in _IDENTITY: + object.__setattr__(value, name, raw[name]) + object.__setattr__(value, "lab1", _freeze_target(raw["lab1"])) + object.__setattr__(value, "lab2", _freeze_target(raw["lab2"])) + object.__setattr__(value, "cross_target_observations", + tuple(_freeze_row(row) for row in raw["cross_target_observations"])) + 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 + + +@_checked +def project_dual_lab_vrrp_summary(raw: bytes) -> DualLabVrrpSummary: + """Project only upstream-validated canonical aggregate bytes, without I/O.""" + aggregate = _upstream.parse_aggregate_canonical_json(raw).to_dict() + projected = {name: aggregate[name] for name in _IDENTITY} + projected["schema_version"] = _SCHEMA + for name in ("lab1", "lab2"): + target = aggregate[name] + if target["status"] == "FAILURE": + projected[name] = target + else: + projected[name] = {"status": target["status"], "target_ref": target["target_ref"], + "records": sorted(target["evidence"]["records"], key=_sort_key)} + projected["cross_target_observations"] = _derive(projected["lab1"], projected["lab2"]) + return _build(projected) + + +@_checked +def parse_summary_canonical_json(raw: bytes) -> DualLabVrrpSummary: + """Validate exact summary bytes and all derived facts; never repair input.""" + 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 _build(parsed) + + +__all__ = ("DualLabVrrpSummary", "DualLabSummaryError", "project_dual_lab_vrrp_summary", + "parse_summary_canonical_json")