Eternet.TestPlanner 1.1.125

Prefix Reserved
dotnet tool install --global Eternet.TestPlanner --version 1.1.125
                    
This package contains a .NET tool you can call from the shell/command line.
dotnet new tool-manifest
                    
if you are setting up this repo
dotnet tool install --local Eternet.TestPlanner --version 1.1.125
                    
This package contains a .NET tool you can call from the shell/command line.
#tool dotnet:?package=Eternet.TestPlanner&version=1.1.125
                    
nuke :add-package Eternet.TestPlanner --version 1.1.125
                    

Eternet Test Planner

Eternet.TestPlanner is a .NET global tool that plans the best tests to run for a git diff. The installed command is etp.

For a Spanish (Argentina) explanation of the current ETP/EAC integration and the repository CI, see ETP, EAC y la CI de Eternet.AspNetCore.

For staged CI adoption of the native producer-build decision, see pre-build decision migration and compiler state-machine impact ownership.

See generated test dispatch contexts for receipt-bound specialization of direct Mediator test-base calls.

For recorded precision, fallback and execution scope explanations, see selection precision report. For the explicit data exception policy, see data input dependencies. For the CI measurement and staged rollout of the efficiency switches, see CI efficiency.

Local loop

For an opt-in local affected-test run with incremental build outputs, shared analysis cache, bounded run retention, fragment retries, and agent output, see the local loop guide. Local and imported-baseline evidence is never certifiable; keep isolated CI runs for publication.

The goal is fast, reviewable affected-test planning for Eternet repositories: run the smallest useful set of tests when there is enough evidence, and widen explicitly when the tool cannot prove a narrower selection. etp is not a general-purpose test oracle and it is not a replacement for nightly, release, or full-suite safety nets.

CI savings self-report

workflow affected-tests finalize writes an additive savings object with schema etp.savings.v1 into affected-tests-receipt.json, a CI savings vs full suite table in summary.md, and one etp-savings: line on stderr; stdout carries only the receipt JSON. The receipt's etp.affected-tests-workflow.v2 schema remains unchanged.

The test projection sums whole-project runner times from the duration baseline for inventory test projects. Actual test machine time sums fragment process wall time, including class and project fragments. The percentage compares only projects with baseline runner observations. The build projection counts the prepared graph closure of every inventory test project; actual build projects come from stage build graph roots and their closures, counted within that same closure, so a forced-full run saves 0%. Projects built outside it (bootstrap tooling, snapshot completion) are reported separately as outsideTestClosureProjectsBuilt. Missing evidence yields partial with reason codes while failed runs or missing prepared graphs yield unavailable. Savings reporting does not change the finalize exit code.

When any compiled-ownership step ran, the savings object also carries an ownership block, a Compiled ownership: cost vs avoided work table in summary.md, and an ownership=<cost>/proven=<n>of<m>/avoided=<p>p token on the etp-savings: line. It weighs the cost of each step (HEAD inventory capture, receipt import enrichment, native and staged proof, the post-build witness, and a candidate replan that HEAD ownership forced) against the projects a global fallback would have affected without the proven sources and the source scopes dropped after the build. rejectedSources counts unproven ownership diagnostics by reason (a source can fail native proof and then staged evaluation) and rejectedShards counts carriers unusable for ownership by reason, so a run shows which limit below it hit.

For CI, pass --test-duration-baseline to workflow affected-tests plan using the latest successful forced-full run's durations/etp-test-durations.mpack. etp ci evaluate reads the receipt block for X6, counts successful measured and partial records, and flags duration baselines older than seven days.

CI efficiency commands

Evaluate a versioned replay corpus against either the frozen CI CSV or a prior evaluation report. Run roots under --records must be named for their corpus case IDs. Pass one or more audit JSON files and any certified differential references when evaluating selection safety:

etp ci evaluate --corpus ci-replay-corpus.json --records replay-records \
  --baseline-csv etp-ci-runs.csv --audit audit-1.json audit-2.json \
  --reference-selection case-id=reference-selection.json --output evaluation

--reference-selection is the differential oracle: every test selected in the reference (for example a frozen baseline CI run's effective selection) must be covered by the executed tests of the case's run root. Method targets compare without a trailing empty (); class and project selections cover the methods they own. A missing test is a possible miss; see docs/ci-efficiency.md for how missing baseline tests are classified.

The output directory contains ci-evaluation-report.json and ci-evaluation-report.md. A threshold with fewer than five applicable records or missing CI timing fields is insufficient-data. etp audit --manifest <effective-selection> --broad-results <full-suite TRX files> checks whether a failed test in the broad run was omitted; a broad run with no failed tests cannot by itself certify the absence of under-selection.

The selected build records etp.build-set.v1 project accounting. Its producer split runs only when it avoids at least 12 projects and 30% of the candidate closure by default. workflow affected-tests build accepts --prebuild-split-min-avoidable-projects and --prebuild-split-min-avoidable-fraction to tune that decision; 0 for the project minimum restores the earlier split eligibility.

The additional selection and execution options are opt-in:

  • --precompile-narrowing on uses certified predecessor compiler evidence to narrow before the build. The default is off; missing or stale evidence widens selection.
  • --shard-long-poles uses the partition settings' process-isolated safe projects and class inventory to divide long whole-project work. The default is off; pass --partition-settings <json> for the host's isolation policy.
  • --build-output-reuse verify checks a local output store while compiling normally; use on only after verify has no mismatches. A mismatch fails the build stage (reuse-invalidated-output-mismatch) and quarantines the entry. On the preview SDK, verify can report a mismatch for an identical tree (see docs/ci-efficiency.md, "Local replays and the differential oracle"). Supply --build-output-store <dir> and a stable logical build-root alias per runner. build-output-store publish|inspect|prune manages certified entries; publish verifies the run root's publication-intent.json (output manifest and package graph bound at finalization) exactly like deferred publication and refuses a run without one (build-output-manifest-missing). --allow-unbound-publication is a developer-only escape that skips those comparisons (the summary reports unbound-publication); CI never uses it. The default local size cap is 2 GiB. A deterministic build with -p:IncludeSourceRevisionInInformationalVersion=false is required for cross-revision reuse. A miss or uncertified output compiles normally.
  • workflow affected-tests finalize --defer-publication (or run with the same flag) writes a publication-intent.json. After reporting certified status, call workflow affected-tests publish --run-root <dir> with the required store, checkpoint, and archive inputs. Publication is resumable; its receipt records each item. Keep legacy evidence staging for runs that never reached deferred finalize.
  • durations export --calibrate --results <TRX directory> --output <file> --format messagepack writes a v2 duration baseline when native execution receipts and attributed TRX time allow calibration. Existing v1 baselines remain readable. An explicit process startup value in CLI or partition settings takes priority over calibrated startup.

Plan widening changes executable obligations, not their authority: the original input-policy hash, source diff and graph evidence remain attached to the plan, including its canonical MessagePack representation. Plans produced by an older tool that lost the policy hash must be regenerated; the build-stage policy check is not bypassed. No payload schema migration is required.

Native lane execution accepts portable manifest separators with Windows extended workspace paths (\\?\...). Filesystem resolution converts separators before checking containment; the same outside-workspace rejection applies.

Portable snapshot composition preserves a validated project identity when its dependency closure is stale. The stale shard contributes no semantic or test inventory authority: a newer build replaces it, or snapshot completion rebuilds the project. Corrupt receipts and unvalidated foreign identities still fail composition instead of reviving older evidence.

Prepare's per-project CacheKey, the DependencyClosureFingerprint derived from it, and PortableGraphFingerprint key the output location MSBuildProjectExtensionsPath by its workspace-relative path (a relative value is resolved from the project directory), or as <outside-workspace> when it lies outside the workspace. Files that evaluation imports from such an external location (for example $(MSBuildProjectFile).*.props beside the restore outputs) stay keyed by content, named relative to the common directory of every graph variant's location plus the target frameworks that import them, so one variant's import never stands in for another's. NuGet's generated *.nuget.g.props/targets are left out because prepare excludes the package imports they declare, the package set they record is keyed through its inputs, and their own contents carry run-specific paths. A per-run build root outside the workspace (such as EAC's EAC_AFFECTED_TEST_BUILD_ROOT) therefore no longer makes every predecessor receipt stale on the next run. Evaluated properties such as DefineConstants or AssemblyName, package references, and workspace imports and inputs stay keyed. Receipts that earlier versions wrote under an out-of-tree root mismatch once and are rebuilt; in-tree layouts keep their keys. GraphFingerprint remains the exact, workspace-bound graph identity. Like the rest of the prepare fast cache, a fast hit does not revalidate files outside Git's view (ignored restore outputs in obj/ or an external output location); a fresh --cache-directory forces a new evaluation.

Build-impact carriers now use etp.build-impact.v7. Full and incremental test entries retain CLR metadata class names (for example Outer+Inner, including generic arity), rather than deriving runner filters from documentation IDs. Existing v5/v6 snapshots must be fully rebuilt before they can seed new deltas; unchanged nested tests in an older base still contain invalid filters. The delta contribution fingerprint algorithm remains unchanged across its producers and verifiers; the full carrier schema gates compatibility.

What etp Is

etp is an affected-test planner for modern .NET repositories that follow the Eternet stack and conventions. It combines cheap repository signals, MSBuild ownership, Roslyn analysis, and Eternet-specific structural patterns to produce versioned manifests that can be executed by Microsoft Testing Platform.

It is designed for repositories that use:

  • SDK-style .NET projects.
  • Central Package Management (CPM).
  • Microsoft Testing Platform v2.
  • xUnit v3 or modern xUnit-style test metadata.
  • Eternet packages and conventions, especially Eternet.Mediator.*, source generators, analyzers, generated pipeline patterns, and Eternet CLI or service contract layouts.

The first supported target is .NET. Other languages and test runners are out of scope for now.

What etp Is Not

etp does not guarantee that a selected subset is complete in every possible runtime scenario. It does not understand arbitrary reflection, dynamic loading, external services, mutable databases, or hidden runtime wiring unless there is a deterministic pattern, semantic edge, history, or explicit fallback rule for it.

etp also does not decide workflow policy. CI still owns retries, timeouts, parallelism, required checks, broad fallback suites, and release gates.

When confidence is low, etp should widen, not guess.

etp is also not trying to be a universal test-selection platform. Building a general-purpose planner that works across languages, build systems, test runners, generated code styles, service frameworks, and organization-specific runtime conventions is a much harder problem. Large engineering organizations can invest in broad dependency indexing, coverage infrastructure, remote build graphs, ownership metadata, and long-running historical feedback loops. That is not the scope here.

The pragmatic bet for Eternet is narrower and more useful: make a fast, deterministic planner that understands our .NET repositories, our package ecosystem, our generators, our analyzers, and the patterns our teams actually use.

Experimental session server

ETP can keep one CLI host warm for the lifetime of a CI flow or local plan. The feature is opt-in: normal etp commands continue to execute in their own process unless ETP_EXECUTION_MODE=server is set.

$session = "local_plan_a"
etp server start --workspace . --session $session
$env:ETP_EXECUTION_MODE = "server"
$env:ETP_SERVER_SESSION = $session

etp workflow affected-tests plan <arguments>
etp workflow affected-tests build <arguments>
etp workflow affected-tests test <arguments>
etp workflow affected-tests finalize <arguments>

etp server stop --workspace . --session $session

Each instance belongs to one workspace, session, tool version and private token. Requests for that flow are serialized; parallel work inside an individual ETP command is unchanged. Separate sessions allow multiple flows on the same machine or even the same workspace without sharing mutable CLI state.

The local transport is a current-user named pipe with length-framed MessagePack. Set ETP_SERVER_PROTOCOL=json before server start only when a readable wire format is useful for diagnosis. The small state descriptor remains JSON and contains process, session, workspace, protocol, version, token and one environment fingerprint; command payloads use the selected pipe protocol. A client with a different environment sends the complete current environment for that request so commands preserve normal CLI semantics across CI steps.

.github/workflows/diff-affected-tests.yml exposes experimental_etp_server for manual runs. Its default is false, and push, pull-request and scheduled runs keep the existing CLI behavior. The reusable replay workflow contains one copy of the plan/build/test/finalize sequence; the boolean only starts the session and selects its transport.

Server lifetime does not make every cache valid for the whole flow. DI singletons reuse the command host, while compiler evidence remains bounded to a quiescent build phase and is invalidated by later builds. Cross-command workflow artifacts use their own exact identities: in particular, build reuses the persisted plan prepare manifest only when workspace, base, head, scope, schema and portable graph fingerprint all match. The cache and transport measurements behind these choices are recorded in the CI ownership audit.

Native SDK-aware graph fingerprint

etp ci graph-fingerprint --workspace . --output artifacts/graph-fingerprint.json prints only the fingerprint to stdout and optionally writes a compact receipt. CI owns exporting that value to GITHUB_ENV; the command works identically locally.

The etp.graph-fingerprint.v1 contract hashes repository-relative paths and raw file content for global.json, NuGet.Config, .csproj, .props, .targets, .slnx and eng/lanes/**/*.json. Paths are ordinal-sorted, hash fields are length-framed, and file content is streamed. Generated/cache directories (bin, obj, artifacts, .etp, .git, .eac, node_modules) are pruned before traversal. Symbolic links in the graph input tree fail explicitly rather than introducing outside-workspace inputs.

Every global.json must declare sdk.version. ETP runs dotnet --version once per SDK context, maps each project to its nearest context, and records requested and effective versions. Projects without a repository global.json share a default SDK probe. Missing SDKs, malformed input, or a probe exceeding 30 seconds fail; no guessed version or fallback fingerprint is emitted. --dotnet-command chooses an explicit dotnet executable when needed.

This replaces EAC's PowerShell graph fingerprint with a new deterministic contract. The hash intentionally changes once during adoption (including ordinal ordering, raw-byte hashing and cache-tree pruning); regenerate dependent evidence. Do not probe the previous fingerprint or retain a second implementation for compatibility.

Native green-tree self-sealing

The native green-tree protocol freezes the reusable obligation before build, then binds that exact obligation to terminal build/test receipts in the same DAG. A post-build refinement cannot silently replace the selection or partition manifest that will later authorize reuse.

CI adapters should use the high-level workflow commands. prepare freezes the pre-build manifests and returns a typed execute or reuse; finalize consumes that preparation plus the current run identity and exact terminal lane outcomes. It returns reused without creating a seal, or writes a transport-independent seal preparation for publication:

etp green-tree workflow prepare --repository <owner/repo> --tested-head <commit> --tested-tree <tree> \
  --selection-manifest <selection.json> --partition-manifest <partitions.json> \
  --continuous-coverage-base <commit> --continuous-coverage-mode diff-affected-tests \
  --analysis-depth semantic --workflow "Diff Affected Tests" --finalizer green-tree-validation \
  --protected-branch main --artifact-root <root> --trusted-store-root <trusted-root> --output <preparation.json>
etp green-tree workflow finalize --preparation <preparation.json> --artifact-root <root> \
  --event pull_request --run-id <current-run> --run-attempt <current-attempt> --pull-request-number <number> \
  --base-commit <commit> --workflow "Diff Affected Tests" --ref <ref> --protected-branch main \
  --head-repository <owner/repo> --finalizer green-tree-validation --verifier <identity> \
  --protected-definitions-unchanged true --build-outcome success --tests-outcome success \
  --output <finalization.json>

These commands own canonical contracts, fingerprints, timestamps, obligation identity, terminal receipts, same-DAG authority, and seal preparation. The CI adapter owns only event/branch policy and trusted-store transport, locking, CAS, and atomic publication.

For the native affected-tests lifecycle, replace the two manifest arguments with --affected-tests-run-root <run-root> immediately after workflow affected-tests plan and before build. ETP reads the authoritative ci-plan.mpack and prepare identity from that run, validates the requested head, tree, coverage base, analysis depth, selection count and partition count, and freezes selection/partitions as .mpack under the green-tree artifact root. It does not emit large JSON payload copies. Compiler-index planning may preserve an effective fast-project depth when a requested semantic plan takes its recorded conservative fallback or control-plane bypass. Green-tree accepts this only with an owned baseline and matching fallback evidence in the plan and workflow state. The frozen selection retains its actual depth and decisions; the obligation retains the requested analysis policy. The small preparation, terminal and publication receipts remain JSON.

This entry rejects dirty workspaces recorded at planning, failed or post-build states, unsupported state schemas, escaping artifact paths and missing/inconsistent plan evidence. It never rebuilds a missing partition or silently falls back to the standalone manifest inputs. The two input forms are mutually exclusive. Existing standalone callers may supply either canonical MessagePack or JSON manifests; their file extensions are preserved when freezing them. Native plans require current v3 state: generate a fresh plan instead of migrating timestamps or relabeling an old post-build state as a pre-build obligation. The workflow configuration comes from the native state rather than a CI-side constant.

When this native input form is used, also pass --affected-tests-run-root <run-root> to green-tree workflow finalize, after native workflow affected-tests finalize succeeds. ETP validates the successful terminal receipt, matching head/tree/base, completed partitions, current artifact set and file hashes, and its binding to the original pre-build plan. Successful GitHub lane outcomes remain independently required; they cannot substitute for missing native evidence. The plan receipt hash is run-local preparation metadata, not part of the reusable obligation identity (plan telemetry and run paths must not invalidate otherwise identical reuse). Native terminal hashes are included in the sealed lane receipts. Exact trusted reuse needs no new test receipt. Frozen native obligation payloads omit elapsed stage timings and cache observations; semantic budgets, selected scope, fallback decisions and partition costs remain bound. The original native plan retains its full telemetry and is bound by its run-local hash. The stable affectedTestsEvidence=native-terminal-v1 validation input requires this gate even if run-local binding metadata is absent. Existing native obligations must be prepared again when upgrading; standalone manifest callers retain their lane gate. Keep the preparation immutable between its creation and finalization, as with the existing obligation. This validates native terminal evidence; it does not re-run tests.

Native seals also carry the effective post-build selection and partition manifests, plus portable partition execution receipts. Native finalization with --reuse-preparation <preparation.json> requires those effective manifests to match the current build, in addition to the pre-build plan binding. Elapsed timings, cache observations and run-local plan identity are excluded from the comparison; selection decisions, budgets and widening reasons remain included. Missing or changed effective evidence fails closed. Checkpoint publication validates the reused partition/fragment coverage and copies the portable evidence into its candidate without inventing local test execution.

Freezing reuse evidence stages an owned snapshot and publishes it atomically. Retries accept an already complete, identical, validated snapshot; conflicting files are preserved and rejected. Tree equivalence is authorized by the canonical policy fingerprint, so both the producing and consuming preparation must opt in with --allow-tree-equivalence. Editing a decision reason cannot grant that permission. Records created under the previous policy must be regenerated.

etp green-tree obligation create --input obligation-draft.json --artifact-root artifacts --output obligation.json
etp green-tree propose --input proposal-draft.json --artifact-root artifacts --output proposal.json
etp green-tree verify --proposal proposal.json --authority authority.json --artifact-root artifacts \
  --required-workflow "Diff Affected Tests" --required-branch main \
  --required-finalizer green-tree-finalizer --current-run-id <run-id> --current-run-attempt <attempt> \
  --verifier-identity eac.private.same-dag --output verification.json
etp green-tree seal --proposal proposal.json --verification verification.json \
  --artifact-root artifacts --record-output trusted-record.json --output prepared-record.json
etp green-tree decide --record trusted-record.json --artifact-root artifacts \
  --expected-repository Eternet/Eternet.Agents.Control \
  --expected-obligation sha256:<hex> --expected-tested-head <commit> --expected-tested-tree <tree> \
  --expected-validation-fingerprint sha256:<hex> --expected-scope-fingerprint sha256:<hex> \
  --expected-policy-fingerprint sha256:<hex>

obligation create requires the validation scope to bind the exact pre-build selection and partition hashes, and requires those two immutable files as its artifacts. propose adds successful terminal receipts whose tested head/tree and lane set must exactly match the obligation. verify treats source authority as a strict same-DAG finalizer contract: authorityKind must be same-dag-finalizer, the current run must still be in_progress, its run id and attempt must equal the caller-provided current run, and every required upstream lane must already be terminal success. A completed run, a workflow_run-style sealer, an external run, or an incomplete/failed needs set is an authority failure (exit 3); there is no asynchronous fallback. Portable proposal rejection remains a typed exit 2. seal produces a publication key, trusted record, hashes, and copy candidates only. It performs no storage lookup, lock, CAS, rename, or copy; the transport adapter owns those operations and can pass --existing-record for deterministic idempotency/collision classification. decide returns reuse only for an exact obligation/head/tree/contract/scope/policy match. Missing or mismatched candidates return typed execute/exit 2; tampered authority certification fails with exit 3.

Green-tree validation receipts

ETP can create and verify a portable, deterministic receipt for a completed validation wave. It owns receipt normalization, scope hashing, expiration, and artifact-table verification; the caller owns CI provenance and transport.

etp validation-receipt create --input receipt-draft.json --artifact-root artifacts/receipts --output receipt.json
etp validation-receipt verify --receipt receipt.json --artifact-root artifacts/receipts
etp validation-receipt decide --receipt receipt.json --artifact-root artifacts/receipts \
  --expected-repository Eternet/Eternet.Agents.Control \
  --expected-tree <tree-sha> \
  --expected-validation-fingerprint sha256:<hex> \
  --expected-scope-fingerprint sha256:<hex>
etp validation-receipt inspect --receipt receipt.json
etp validation-contract fingerprint --input validation-contract.json
etp validation-scope fingerprint --input validation-scope.json

Receipts are intentionally bounded to compact evidence (128 files, 64 MiB per file, 128 MiB total). A verification failure is a conservative miss, not a reason to skip the normal build or tests. verify checks portable receipt integrity; decide additionally compares the receipt with the current repository/tree/contract/scope obligation and exits with 2 for a conservative execute decision.

The fingerprint commands accept only their versioned schemas and reject missing or unknown properties. A validation contract also requires a non-empty externalInputs string map for mutable inputs outside the Git tree (for example test-platform/action/package-source identities, test arguments, or relevant MSBuild properties). Scope inputs are therefore never silently reduced before hashing. JSON numbers are normalized canonically (so equivalent spellings do not create avoidable misses), and verify applies the same strict raw scope check as create and decide. A missing, unreadable, or malformed receipt from decide produces a structured execute decision with exit code 2. Decision identity is published beneath expected and actual objects, with camel-case field names, so adapters can consume one stable contract.

Build certification follows a stricter two-phase contract. verify and decide are repeatable, read-only inspections; they never consume proposal state. Only build-certification seal consumes the proposal nonce, after the artifact table and verifier identity have passed validation. A second seal of the same proposal is rejected with nonce-reused. This lets trusted consumers inspect or reuse an accepted certification without turning ordinary reads into order-dependent behavior.

How Selection Works

Planning is intentionally layered from cheap to expensive:

  1. Git diff discovers changed files between --base-ref and --head-ref.
  2. Global fallback rules classify changes to solution, build, package, props, targets, workflow, and other broad-impact inputs.
  3. MSBuild ownership maps changed inputs to affected projects and reverse project-reference test candidates.
  4. Roslyn semantic analysis narrows candidates to changed declarations, referencing tests, callers, relationships, fixtures, attributes, generics, interface/override links, and Eternet.Mediator pipeline edges.
  5. Structural contract analysis supplements Roslyn where the tested contract is not a direct symbol reference.
  6. The manifest records selections, confidence, reasons, graph paths, fallback reasons, telemetry, and cache diagnostics.

A multi-targeted project has several MSBuild graph nodes. Its inputs, imports, references and packages are the union over all of them, while scalar evaluation properties (TargetFramework, DefineConstants, effective package versions) come from one representative evaluation: the node with the most items, then the first framework declared in TargetFrameworks (the project's default target), then an ordinal comparison. MSBuild enumerates graph nodes in an order that varies between processes, so the choice never depends on node order; repeated plans produce the same GraphFingerprint and project cache keys.

The planner prefers method selections, then class selections, then project selections. Project selections are conservative fallback evidence, not a failure.

Eternet Patterns

etp is intentionally opinionated about Eternet repositories. That is the point: it should learn the frameworks and conventions that matter here instead of pretending to be a generic black box.

Current deterministic signals include:

  • MSBuild project ownership and reverse test-project closure.
  • Roslyn symbol references from tests to changed production symbols.
  • Changed test method and changed test class source detection.
  • Related symbol traversal for callers, fixtures/helpers, attributes, constructed generics, interfaces, overrides, and selected deep relationships.
  • Eternet.Mediator pipeline relationships such as generated execute pipeline steps, request/response links, validators, retry providers, execution policies, branch paths, generated pipeline test-base anchors, and generated pipeline ordering.
  • Eternet.Mediator helper and dependency closure: changed pure static helpers select consuming generated steps and owning pipeline tests; source-owned workflow dependencies select bounded consuming pipelines or widen with an explicit Mediator budget fallback.
  • CLI structural contracts: production declarations such as new Command("contracts") plus tests that invoke the same command path, for example contracts scaffold.

The structural-contract layer exists because many real tests exercise public contracts rather than calling implementation symbols directly. A CLI test may call RunAsync("contracts", "scaffold") without referencing ContractsCommandModule; an HTTP test may exercise a route without referencing the endpoint builder; a generated Mediator pipeline test may assert generated behavior through contracts. These are not semantic symbol edges, but they are real evidence when the pattern is deterministic.

Future pattern families should follow the same rule: add reproducible, auditable evidence first, and only widen when evidence is missing.

Mediator Impact Graph

The Mediator layer models generated pipelines as first-class impact facts. It tracks handlers, requests, responses, generated steps, step inputs and outputs, execution policies, retry providers, validators, explicit branch paths, generated pipeline test-base anchors, synchronous pure helpers, narrow infrastructure adapters, and source-owned workflow dependencies.

When those facts are complete, etp can select direct step tests, generated pipeline test-base classes, branch-specific tests, retry or validator behavior tests, and pipeline tests reached through helper or dependency closure. The manifest records reason codes such as ChangedMediatorStepSelectedPipelineTests, ChangedMediatorPureHelperSelectedPipelineTests, ChangedMediatorValidatorSelectedPipelineTests, and GeneratePipelineTestBaseAnchoredPipelineSelection so reviewers can audit why a test was selected.

The graph stays conservative. etp widens to candidate test projects when Mediator ownership is ambiguous, helper purity is not proven, branch evidence is missing, dependency fan-out crosses unrelated feature areas, or global build inputs changed. These fallbacks are explicit safety behavior, not silent misses.

Peer compiler generators are inventoried through etp.peer-generated-sources.v1. The manifest carries only generator-relative paths, SHA-256 hashes, byte counts, project build identity, and SDK identity; it never transports generated source files or absolute obj/RAM paths. ETP accepts that evidence only when the receipt hash and the adjacent IL/PDB graph are complete. Otherwise it widens with one explicit reason such as peer-generated-sources-assembly-impact-incomplete; malformed and legacy manifests remain separately observable and fail closed.

Promotion gates can require the exact compiler projects whose evidence must be complete:

etp build-index --root artifacts/impact-index --allow-partial `
  --require-project tests/Eternet.Agents.Control.Cli.Tests/Eternet.Agents.Control.Cli.Tests.csproj `
  --require-project tests/Eternet.Agents.Control.Web.Tests/Eternet.Agents.Control.Web.Tests.csproj

--allow-partial may tolerate unrelated incomplete shards, but it never hides a missing or unusable required project. The JSON receipt records a required-project-carrier-missing:<path> or required-project-carrier-unusable:<path> reason and the command exits with code 2.

Repository snapshot promotion should use the selection-workspace gate instead of maintaining a hard-coded list of important tests:

etp build-index --root artifacts/impact-index --allow-partial `
  --workspace $PWD `
  --prepare-manifest artifacts/etp-prepare.json `
  --require-complete-workspace

For inspection immediately after a build with redirected intermediates, also pass the current build's evaluation properties using repeated --msbuild-property NAME=VALUE arguments. For example, this repository uses --msbuild-property "EacAffectedTestBuildRoot=$buildRoot". This allows MSBuild to import the actual restored NuGet targets, including Compile Remove rules. These are caller-supplied current paths, never recovered from a producer receipt. Restored NuGet compile items can move between package caches only when package identity, version, internal source path and compiler-recorded content hash agree. Unrecorded source files and changed package content still invalidate the shard. ci refine-post-build accepts the same repeated evaluation-property arguments. workflow affected-tests build automatically forwards the context from its own restore receipt to both pre-build and post-build refinement, including ETP-owned artifacts output paths.

Here “complete workspace” means every standalone test execution project plus its transitive ProjectReference closure. Standalone test projects require a complete test inventory; referenced C# projects require usable semantic impact evidence. Unrelated applications, benchmarks, plugin fixtures, and unsupported non-C# project types are not promotion requirements unless they occur in that closure. This is independent of the current diff's CandidateTestProjects, so the promoted snapshot remains safe for a later change anywhere in the test execution graph.

Compatibility note: ETP 1.1.30 briefly interpreted --require-complete-workspace as every project in etp.prepare.v2. Consumers that intentionally need evidence for a non-selection project should continue to add --require-project <path> explicitly; normal snapshot promotion should adopt the selection-closure behavior above without repository-specific lists.

Prepare a full-v3 promotion candidate locally before the host takes its store lock:

etp build-impact-snapshot publication prepare `
  --workspace . `
  --source-root artifacts/impact-index `
  --prepare-manifest artifacts/etp-prepare.json `
  --repository Eternet/Eternet.AspNetCore `
  --etp-version 1.2.3 `
  --scope repository `
  --input-fingerprint <hex> `
  --commit <40-hex-sha> `
  --require-complete-workspace `
  --candidate-root artifacts/snapshot-candidate `
  --pointer-output artifacts/latest.txt.candidate `
  --existing-object-root <optional-commit-object-root> `
  --existing-pointer <optional-latest.txt> `
  --output artifacts/snapshot-publication.json

ETP discovers and validates the current etp-layout-v3 carriers, applies the same test-execution-graph inspection as build-index, and emits create, no-op, or reject. A prepare manifest transported from another runner is accepted only when its HeadRef matches the exact publication commit and its project/change paths remain repository-relative. ETP resolves those paths in the destination workspace and validates the carriers against that checkout; the original manifest is not rewritten. A workspace-path difference alone does not require rebuilding tests. Changed destination inputs still invalidate carrier evidence, and partial snapshots remain ineligible for promotion.

Both publication prepare and publication publish accept repeated --predecessor-root <full-v3-root> arguments, ordered oldest first (for example, owned baseline followed by current pre-build evidence). --source-root is newest. ETP uses the same whole-project replacement as post-build refinement: new evidence replaces every prior TFM of that project, even when the new carrier is invalid. An old valid carrier never repairs an invalid current one. The ZIP contains exactly the chosen project carriers and their companion files; only the current root contributes non-layout metadata. The composed coverage is validated against the destination checkout before pointer eligibility is granted. Missing, overlapping or duplicate roots and colliding archive paths fail rather than dropping inputs or selecting an arbitrary winner. Single-root callers and the v3 snapshot restore format are unchanged; no consumer-side tree merge or schema migration is needed.

For an initial complete seed, opt into workflow affected-tests build --complete-snapshot (also available on run). After the selected build and before post-build refinement, ETP compares the validated current and predecessor carriers with the entire test-execution project closure. Only missing or unusable projects are submitted to an additional bounded build; their project dependencies remain MSBuild-owned. This does not add test scopes, fragments or partitions. A complete existing carrier set performs no extra build. The extra build reuses the native output root and has its own receipt, timing and snapshot-completion build-root count in the final summary. The coverage decision is recorded in build/snapshot-completion.json; publication inputs include the new carrier root and preserved predecessors. A failed compilation or remaining coverage gap fails the stage and cannot produce publication authority. No-op plans cannot use this option to invent executed test evidence. The option is not implicit on ordinary incremental runs. Incremental parent-checkpoint publication instead consumes the verified fragments already carried by the owned v2 ephemeral overlay; it does not rescan an external fragment directory after tests finish.

When an unusable required predecessor forces a consumed checkpoint to be reseeded, the native build automatically repairs the carrier closure required by the original plan before running tests. Candidates removed by refinement remain part of that publication contract, but their tests are not added back. This bounded reseed repair uses the same completion receipts and validation; a complete workspace still requires the explicit --complete-snapshot option.

Source-owned v8 deltas cannot advance a changed project from an expanded v7 carrier. Native builds detect that transition before compilation and record checkpoint-carrier-schema-full-reseed:<project>. The current build stage has one global carrier mode and a checkpoint has one schema fingerprint, so this one-time migration rebuilds the already selected execution graph with full carriers and publishes a new mixed-schema seed. It preserves the selected tests and every validated predecessor root; unrelated v7 projects remain reusable. It does not force a workspace build or add tests. Legacy projects without changed C#/Razor inputs do not request this migration.

A future per-project migration can introduce an effective carrier mode per project in the MSBuild graph and a certified full-carrier replacement operation in the ephemeral overlay. That operation must bind the replacement to the tested head, preserve unchanged project contributions, and migrate the checkpoint schema without interpreting a v7 expanded contribution as a source-owned v8 delta.

After workflow affected-tests finalize succeeds, use --affected-tests-run-root <run-root> instead of --source-root, --predecessor-root, --prepare-manifest, and --msbuild-property on either publication command. ETP verifies the native terminal and exact clean tested checkout before any publication write, then loads the build-recorded roots and restore context from build/snapshot-publication.mpack, whose hash is bound by the terminal receipt. Manual input overrides cannot be mixed with this mode. Owned run/workspace paths in evaluation properties are rebased when transported; external SDK, package and feed paths retain their identity and must validate at the destination. Baseline file inventory and hashes are revalidated too.

Compatibility: older runs without this descriptor must execute a fresh native build/test/finalize before using native publication. There is no inference from directory names and no conversion of old receipts into publication authority. Failed rebuilds clear the descriptor binding; a previous green attempt cannot authorize publication. A green terminal authorizes inspection, not completeness: the normal full-workspace coverage gate still controls pointer eligibility.

Consumers restore only exact complete snapshots through ETP as well. The fingerprint command owns the compatibility key, and restore validates the pointer, immutable metadata, publication proof, archive SHA-256, carrier count, and ZIP containment before atomically creating a fresh local root:

$fingerprint = etp build-impact-snapshot fingerprint --workspace .
etp build-impact-snapshot restore `
  --workspace . `
  --store-root <durable-store> `
  --repository Eternet/Eternet.AspNetCore `
  --etp-version 1.2.3 `
  --scope repository `
  --input-fingerprint $fingerprint `
  --expected-commit <40-hex-base-sha> `
  --output-root artifacts/restored-impact-index `
  --output artifacts/snapshot-restore.json

A missing or non-exact pointer produces a typed unavailable receipt and no local payload; it does not authorize a full-suite fallback. Corrupt storage fails the command, and an existing destination is never overwritten.

The exact base of a pull request often has no certified snapshot yet: the protected branch's run for it may still be running, may have been cancelled by a newer push, or may have been skipped by path filters. --max-ancestor-distance <n> (0-256, default 0) opts into a bounded fallback: when the exact object is unavailable, ETP reads the first-parent lineage of --expected-commit from --workspace and restores the nearest ancestor whose complete snapshot exists under the same input fingerprint and scope. ETP searches its current package directory and at most seven recently modified sibling package directories, checking candidate commits in first-parent order. Cross-version reuse requires an explicit matching EvidenceCompatibilityVersion in certified snapshot metadata. The ETPVersion in that metadata remains the actual producer and must match the storage directory. Metadata from before this field was introduced remains usable only with the same ETP package version. A newer reader must bump CurrentEvidenceCompatibilityVersion whenever it changes the meaning or required shape of compiler carriers, graph inputs, task/ISG output, snapshot context, or their consumers; the snapshot metadata SHA in the publication proof binds the recorded epoch. A package release without such a change can reuse the previous package's certified evidence. The generator stamps the carrier epoch; the build task copies it into its canonical receipt and stamps any assembly-impact graph it computes. Publication checks every selected receipt, including carriers drawn from predecessor roots, and checks the assembly epoch when a graph exists. Archived receipts retain their actual build-task producer versions and bind the carrier and assembly graph hashes. Archive and green-pointer paths remain under their original etp-<version> directories. After a complete publication, ETP atomically registers its producer namespace in the repository's windows/evidence-versions.v1.json catalog. This bounded catalog is a discovery hint, never a substitute for snapshot metadata and proof validation. It keeps the eight most recently registered package versions under a shared file lock; retention may remove an older namespace, which restore skips. A missing or malformed catalog uses a bootstrap scan of at most 64 version directory entries plus one truncation sentinel; the next complete publication uses that same bounded scan to rebuild the catalog before registering its own version. A completed, pointer-eligible publication writes a small epoch marker under its version namespace before catalog registration. Bootstrap checks at most 64 such markers before choosing seven siblings, so rejected or incomplete namespace directories cannot crowd out a completed producer. Existing namespaces without this marker are not migrated into cross-version discovery; their same-version objects retain the existing metadata and proof checks. The marker is only a discovery hint: every candidate still needs its own compatible metadata and publication proof. The catalog records when its bounded selection omitted namespaces, and restore can refill slots vacated by retention from a bounded scan. Directory enumeration order is not guaranteed, so that fallback can miss a compatible version once the input bound is reached; the failure receipt reports snapshot-version-scan-truncated. An exact object in the running package is restored before any catalog read or sibling discovery. ETP lists each selected fingerprint's commits directory rather than probing every ancestor. A metadata-only epoch gate checks at most 24 candidate objects per search; legacy or incompatible objects do not consume the three full proof/archive validations. The receipt reports compatibilityMetadataChecks and incompatibleEvidenceObjects. If the metadata cap is reached it reports snapshot-compatibility-metadata-limit; if candidate objects have no compatible epoch it reports snapshot-evidence-compatibility-unproven. If older version directories were omitted and no object restores, the receipt includes snapshot-version-scan-truncated. The restored receipt reports snapshotEtpVersion for publisher package provenance and snapshot-compatible-version-restored when it differs from the running package. The receipt then carries the restored commit, requestedCommit, ancestorDistance, ancestorSearchMilliseconds, and the reason codes of the exact miss followed by snapshot-ancestor-restored. When nothing matches it reports snapshot-ancestor-not-found, adds snapshot-graph-fingerprint-changed when the same lineage exists only under another fingerprint (the change edited build-graph inputs, so no compatible compiler baseline can exist), or snapshot-ancestor-lineage-unavailable when the workspace cannot prove the lineage. With distance zero, ETP may still restore the requested commit from a compatible package version; it does not search ancestors.

The restored commit is the planning base. Pass it as --base-ref and pass the receipt to workflow affected-tests plan --build-impact-restore-receipt: ETP fails closed when a restored receipt's commit is not the resolved plan base or its outputRoot is not --build-impact-root, because planning from the requested base with the ancestor's compiler facts would drop every change between the two from the diff. Planning from the ancestor widens the diff instead: those changes count as changed, so the selection is a superset of the exact-base selection. The plan adds build-impact-baseline-ancestor to its reason codes and a Compiler baseline section to plan/summary.md.

When a plan has no --build-impact-root, it records build-impact-baseline-unavailable in its reason codes, writes etp affected-tests-plan event="baseline-unavailable" with the restore reason codes (not-supplied without a receipt), selected and inventory test projects, selected tests and the historical duration of the selection, and renders the same estimate in the Compiler baseline section of plan/summary.md. Neither pre-build nor post-build compiler refinement can narrow such a plan, so the cost is shown rather than widened silently.

Partial reuse across a build-graph change

A change that edits a .slnx, .csproj, .props or .targets file changes the graph fingerprint, so no snapshot matches it. --allow-partial-graph-reuse (with an optional --dotnet-command) lets build-impact-snapshot restore continue after the same-graph exact and ancestor searches fail. ETP searches the requested commit and up to --max-ancestor-distance first-parent ancestors, nearest first, across graph fingerprints. An optional --green-branch main pointer can prefer an object for the same candidate commit, but cannot skip a nearer ancestor. ETP checks the pointer's repository, branch, tool version, scope and immutable metadata hash. For every candidate object it validates, ETP fingerprints that commit's own tree from the object database with the etp.graph-fingerprint.v1 rules (so --input-fingerprint and the store keys must come from etp ci graph-fingerprint; a key from the older build-impact-snapshot fingerprint never matches), reading each input as a clean checkout would write it and resolving SDKs on this machine. An object must pass the normal immutable metadata, proof and archive checks. This can reach the last green snapshot when a later main merge changed the graph but its run failed.

Discovery lists at most 32 graph-fingerprint directories under each of the selected package version roots, then checks their scoped commit directories for only those candidate commits. It applies the same 24 metadata-check and three full-validation budgets. More than 32 fingerprint directories in a selected root excludes that root's partial listing while other roots may still yield a certified snapshot. The receipt includes snapshot-partial-reuse-fingerprint-scan-truncated if any selected root was skipped; an unavailable receipt reports that reason when no candidate restores. A shallow checkout may still restore the exact requested commit when its tree is present, but cannot use ancestors whose first-parent lineage it cannot prove. If that exact candidate does not restore, the receipt reports snapshot-partial-reuse-lineage-unavailable. The bounded counts in the receipt are partialFingerprintDirectories, partialCandidateObjects and partialValidationAttempts; it also reports ancestorSearchMilliseconds, and a successful ancestor restore reports ancestorDistance. Directory listing cost still depends on the number of commit entries within those 32 fingerprints and the store's latency. A match proves that every difference between the snapshot's graph and the workspace's graph is a graph input file changed between the restored commit and the head; a different effective SDK, a symbolic link or submodule, or an input the commit does not contain leaves no match. The workspace must also be exactly its HEAD tree: its fingerprint (--input-fingerprint) has to equal the HEAD tree's, computed the same way, or the search stops with snapshot-partial-reuse-workspace-graph-unproven. That rules out uncommitted or untracked graph inputs, nested repositories and changed checkout conversions, none of which a commit diff shows. The receipt uses schema etp.build-impact-snapshot-restore.v2, which planners that predate partial reuse reject, and adds graphReuse="partial", snapshotInputFingerprint, commitGraphFingerprintMilliseconds and the reason code snapshot-partial-reuse; the restored root gets an etp-partial-graph-reuse.json marker, and a plan refuses such a root without its partial receipt. Planners that predate partial reuse do not know that marker: handing one only the restored root, without the receipt, is unsupported and is not guaranteed to be rejected. Always pass the receipt with the root. A failed search ends with snapshot-partial-reuse-not-found, snapshot-partial-reuse-graph-unchanged, snapshot-partial-reuse-workspace-graph-unproven or snapshot-partial-reuse-commit-graph-unavailable; bounded discovery can instead report snapshot-partial-reuse-lineage-unavailable, snapshot-partial-reuse-validation-limit or snapshot-partial-reuse-fingerprint-scan-truncated. Without the option nothing changes.

build-impact-snapshot publication publish --green-branch main advances the stable pointer under green-branches/main.json only with --affected-tests-run-root and --require-complete-workspace. ETP revalidates the run's terminal green evidence, exact tested checkout and complete snapshot before publishing the object and atomically advancing the branch pointer. It recomputes the checkout's graph fingerprint and rejects a mismatched --input-fingerprint; --dotnet-command selects the same SDK resolver used for restore. The default GitHub Actions verifier additionally requires GITHUB_EVENT_NAME=push, matching repository, branch ref, commit SHA and run ID. It checks that origin identifies that repository, fetches the branch, and proves that the tested commit remains on its first-parent history. The CLI also accepts --green-promotion-provider gitlab-ci, which verifies a GitLab push pipeline using GITLAB_CI, CI_PIPELINE_SOURCE, CI_PROJECT_PATH, CI_COMMIT_BRANCH, CI_COMMIT_REF_NAME, CI_COMMIT_SHA, CI_PIPELINE_ID and CI_SERVER_URL with the same remote-branch check. PR, merge-request and manual runs cannot promote the pointer. Other CI hosts can implement IBuildImpactSnapshotGreenPromotionVerifier and call the public BuildImpactSnapshotPublisher.PublishAsync(request, verifier, token) overload. An older run cannot replace a newer first-parent commit; an unrelated lineage is rejected. Other branches use their own paths, for example green-branches/release/stable.json. The ordinary fingerprint-local latest.txt and immutable snapshot objects continue to work without this opt-in.

workflow affected-tests plan then decides per project, in the baseline-partial-reuse phase, before anything reads the owned baseline copy. Projects are keyed by their workspace-relative project path, not by prepare's CacheKey. From git diff --name-status --no-renames between the restored commit and the head, limited to graph inputs:

  • An added or modified project file of the head graph invalidates that project (project-added, project-file-changed).
  • Every project that references an invalidated project, transitively, is invalidated (dependency-invalidated:<reference>). Prepare's reference edges come from an evaluation without the build's properties, so a reference that exists only under, say, Configuration=Release is missing there. Whenever some project is invalidated this way, the head graph is evaluated again as a normal build with the plan's build evaluation properties (--msbuild-property values and the configuration), and invalidation follows the union of both edge sets. It also follows the compiler references that the snapshot's own build recorded in each retained carrier: a project whose compilation received the assembly of an invalidated project, known by the assembly names its carriers recorded, is invalidated too (compiler-reference-invalidated:<reference>; received outputs match with or without their extension). Without a readable carrier, the name the snapshot build produced is unknown (build-injected properties, global-property variants, collisions), so every project that received any project assembly is invalidated with it (compiler-reference-ambiguous:<reference>). A carrier whose payload is incomplete may have dropped its reference list, so it counts as having received every invalidated project (compiler-references-incomplete:<reference>; report referenceClosure="build-properties+compiler-references"). Those references are the compiler's real inputs, so they include project references that targets add while building, such as the SDK's IncludeTransitiveProjectReferences, which no evaluation shows. Dependencies expressed through MSBuild task calls that produce no compile reference (in EAC, AfterTargets="Build" manifest and localization steps) do not change compiler output and are outside compiler evidence, as they already are for same-graph reuse. A ProjectReference or Import whose condition or path reads a property that only ETP's build injects (output layout, artifacts paths, compiler server, EternetTestPlanner*), a ProjectReference added inside a target, or a failed evaluation invalidates every project (reference-closure-unproven:<file>); properties assigned from injected ones, directly or through other properties in the scanned files, count as injected. A restored partial root carries a marker until the workflow prunes its owned copy, and every build-impact reader (ci plan, publication, overlays) rejects a marked root.
  • A solution edit invalidates only projects whose own Project entry was added, removed or changed (solution-entry-changed:<solution>), and every member when solution-level configurations change; folders and solution items change nothing. The workflow never evaluates or builds through a repository solution, but lane producers may. A solution that cannot be read invalidates every project (solution-entries-unproven:<solution>). Whether a solution change selects tests is still the planner's decision.
  • Anything else invalidates every project: a removed or renamed graph input (graph-input-removed:<path>), global.json, NuGet.Config, lane or input-exception files (graph-input-changed:<path>), any added or modified .props or .targets (graph-import-changed:<path>; prepare evaluates without the build's properties, so its import lists cannot bound what conditional imports reach in the real build), a project file outside the head graph (graph-input-unattributed:<path>), any .gitattributes change, which decides the checkout bytes of unchanged graph blobs (checkout-attributes-changed:<path>), any uncommitted, untracked, assume-unchanged or skip-worktree graph input or .gitattributes, or untracked nested repository, in the workspace (working-tree-graph-input-changed:<path>), a diff that cannot be read, or carriers outside every per-project directory.

Carriers of invalidated projects, of projects outside the head graph, and of layout directories whose build receipts do not name one project are deleted from the owned copy and its manifest is rewritten, so every later stage sees them as absent: absent evidence is already built and planned conservatively. Retained carriers still pass the usual per-carrier validation. The plan records snapshot-partial-reuse, writes plan/partial-graph-reuse.json with reused and invalidated projects and their reasons, and adds a Partial compiler baseline reuse section to plan/summary.md. When no carrier remains, the baseline is dropped and the plan records snapshot-partial-reuse-invalidated with build-impact-baseline-unavailable. A partial baseline cannot seed a checkpoint overlay or parent checkpoint, and its workflow state uses schema etp.affected-tests-workflow-state.v5-partial-graph, which older builds reject. The build reports baseline="owned-full-v3-partial" and uses the baseline for pre-build and post-build refinement, but snapshot completion and the snapshot and checkpoint publication inputs never include it, so certified evidence is always built under the head's own graph.

For a first-green same-PR checkpoint, the native publication form is:

etp build-impact-delta publication prepare `
  --workspace . --affected-tests-run-root C:/EtpWork/run `
  --publication-root C:/EtpWork/publication `
  --repository owner/repository --pull-request-number 123 `
  --head-sha <tested-head> --fallback-target-sha <merge-base> `
  --graph-fingerprint <64-hex-graph-fingerprint> `
  --candidate-root C:/EtpWork/publication/checkpoint-candidate `
  --pointer-output C:/EtpWork/publication/checkpoint-pointer.json

This consumes the native original plan, build-recorded carrier roots (including predecessors), and actual restore properties. It verifies the exact clean checkout, terminal hashes, every build exit code, and exact partition/fragment execution coverage before creating build/test attestations. Those small JSON receipts bind the original terminal hash; receipts/native-workflow.json is retained and checked with the candidate. CI must not generate substitute green receipts or inspect native payloads to reconstruct these inputs. The plan stays canonical MessagePack.

This form creates a current-head full-v3 seed (baseSnapshotSha == headSha), not a delta against an inferred parent. Do not combine it with explicit plan, prepare, carrier, execution, MSBuild or parent inputs. Parent-chain publication still needs a recorded planning checkpoint context and compiler delta lifecycle; native runs without that context reject parent overrides rather than silently rebasing them. A no-op without executed build/tests cannot mint execution evidence. Full seed coverage remains mandatory; a green run without required carriers is rejected. Checkpoint carrier requirements use the same test-execution dependency closure as complete snapshots. A global build input can mark unrelated reverse consumers (for example benchmarks or tools) affected without making their carriers part of that closure. If a test references such a consumer, it becomes required normally; unknown affected projects remain required rather than being assumed unrelated. Every required target framework must have usable compiler evidence, and first-green candidate tests require complete inventories. This changes no checkpoint schema or stored object identity and does not permit publication with missing required evidence. Publish the matching full snapshot through native snapshot publication before advancing the checkpoint pointer. The host still owns locking, CAS and transport. Candidate/pointer outputs must remain below the workspace and outside the input run, evidence and object-store paths.

Publication I/O runs with bounded parallelism (ETP_PUBLISH_PARALLELISM, default min(processors, 8)): candidate files are hashed from the bytes as written, receipts and fragments are read concurrently, and the tool-root fingerprint hashes files in parallel. Setting ETP_TOOL_FINGERPRINT_CACHE=default (or a directory path) additionally caches per-file digests of the tool installation under %LOCALAPPDATA%\etp\tool-fingerprints, keyed by relative path, length and last-write time, so a re-extracted dotnet tool install only re-hashes files whose metadata changed. The fingerprint value is byte-identical to the sequential computation; the cache is opt-in, never written inside the tool root, and any unreadable cache is ignored and rewritten. Snapshot publication streams each object through a single hash pass, verifies the copy by read-back and still commits with one directory move.

Pass --require-complete-workspace when incomplete coverage must reject publication. Without that flag, a partial but valid snapshot can be prepared for scopes such as pull-request-<number>; its receipt and proof report completeWorkspace: false and pointerEligible: false. A create candidate contains exactly impact-index.zip, snapshot-metadata.json, and snapshot-publication-proof.json. The ZIP is byte deterministic; v3 metadata retains the existing restore contract and binds both archive and uncompressed content SHA-256 values. The proof binds the metadata bytes and the complete carrier-coverage identity. An identical existing object is accepted only when all three artifacts validate and match. The optional existing pointer is never modified; its SHA-256 is returned as existingPointerSha256 for the host's compare-and-swap.

Pointer eligibility is a coverage fact, not a scope or event policy: pointerEligible is true exactly when workspace coverage is complete. The host owns branch, event, and scope policy. --pointer-output independently expresses that the host wants ETP to materialize a local candidate; when it is omitted, pointerPath is null even for an eligible snapshot. For incomplete snapshots, no pointer is written even if the option is supplied. The host must require pointerEligible: true before attempting a pointer update.

The host remains responsible only for destination paths, the store lock/CAS, atomic copy of the immutable object, and atomic replacement of the pointer. It must not rebuild the archive, metadata, proof, or publication decision.

Why Dogfooding Matters

Dogfooding is the main way etp gets better. The hard cases do not show up in abstract examples; they show up when Eternet repositories use real Mediator pipelines, source generators, analyzer-driven conventions, CLI surfaces, contracts, shared test fixtures, generated files, and workflow-specific test commands.

Every dogfood run gives us concrete evidence:

  • Which diffs narrowed correctly to method or class scope.
  • Which diffs widened to project scope and why.
  • Which fallback reasons are acceptable safety behavior.
  • Which fallback reasons point to a missing Eternet pattern.
  • Whether the emitted MTP fragments run in the target CI environment.
  • Whether audit evidence from broad TRX or coverage results confirms the selected subset.

P3 Mediator dogfood showed that generated-step, pure-helper, and validator changes can select one direct method plus one generated test-base anchored class instead of falling back to the whole candidate test project. Repeated fallbacks should follow the same loop as the CLI structural-contract selector: record the case, classify whether the broader selection is still the correct safety behavior, and promote only stable Eternet-owned patterns into deterministic extractors.

That feedback loop is more valuable than trying to design a universal planner upfront. When a fallback repeats, we can decide whether it is a real risk that should stay broad or a recognizable pattern that deserves deterministic support. The CLI structural-contract selector was added exactly that way: a real EAC diff changed ContractsCommandModule, tests exercised contracts scaffold, and the old semantic-only graph could not connect those facts. The fix was not a one-off special case; it became a reusable structural-contract pattern.

The expected workflow is:

  1. Run etp in shadow mode on real Eternet changes.
  2. Compare the manifest with the tests humans would have run.
  3. Use etp audit and broader suites to catch misses before enforcement.
  4. Promote repeated dogfood findings into deterministic pattern extractors.
  5. Keep broad safety nets for release, nightly, and high-risk changes.

This keeps the tool honest. It improves because our repos expose real failure modes, not because it claims to understand every possible software system.

CI planning receipts

Native affected-tests workflow

etp workflow affected-tests is the reusable orchestration boundary shared by local runs and CI. It owns the semantic stages instead of requiring a consumer PowerShell workflow to reconstruct them:

etp workflow affected-tests plan --workspace . --base-ref origin/main --head-ref HEAD --github-output $GITHUB_OUTPUT
etp workflow affected-tests build --workspace . --run-root artifacts/affected-run --github-output $GITHUB_OUTPUT
etp workflow affected-tests test --workspace . --run-root artifacts/affected-run
etp workflow affected-tests finalize --workspace . --run-root artifacts/affected-run --summary

Planning can bind runner partition capacity with --partition-settings <json> --execution-slot-count <positive-int> and, for a full-suite duration reseed, --whole-project-fragments. ETP writes the adjusted settings into the run's owned inputs without changing the source file. To consume an immutable shared duration baseline within the same planning process, use --duration-baseline-store-root <directory> --duration-baseline-repository <owner/repo> instead of --test-duration-baseline <file>. ETP verifies the store pointer, manifest, payload length and SHA-256 before copying the baseline into the run. An unavailable store leaves planning without that advisory input; the phase receipt records the reason and whether a verified snapshot is stale.

run executes the same four stages in-process. test --partition N is the matrix form; omitting --partition executes every partition with bounded parallelism. Every stage consumes the portable etp.affected-tests-workflow-state.v3 under the run root. Generated artifact paths are relative to the run root so platform adapters can move that directory between runners. Planning captures its supplied compiler baseline under inputs/build-impact-base, with a single inputs/build-impact-base.mpack integrity inventory bound by hash in the small state receipt. When an overlay is supplied, ETP first verifies its original authority, then rebases it to the owned baseline before planning. A current-schema fingerprint does not cover the base index root, so the rebase only moves that location; a legacy overlay is certified once in the current schema. The overlay is read, verified and written once, and decoding keeps one instance of each repeated string. A build verifies its consumed parent once for every current-overlay materialization it feeds. The overlay, its prepare identity, and its certified parent manifest are copied under inputs/ and bound by path, size, and SHA-256. Build, finalize, and checkpoint publication recheck those bindings. The original inputs are not needed on the next runner. No JSON duplicate of the baseline inventory is written. Transport the run's inputs once, using the platform's ZIP envelope; do not copy the original baseline separately.

finalize writes the complete machine-readable receipt to the run root and emits JSON by default. Use --summary in CI to emit the compact Markdown summary instead; failed runs include reason codes, failed test names, bounded assertion messages, and TRX evidence paths while retaining the full artifacts for follow-up diagnosis. Capturing bytes is not a completeness or trust promotion: compiler evidence is still validated against the current workspace. Missing, additional, changed or linked files fail instead of reverting to source planning. Link rejection is scoped to the declared source/run roots and their contents, not platform ancestors: a runner may mount its RAM-disk through a parent junction.

When required tests do not certify the run, finalize states why, because each cause needs a different next action:

Reason code Meaning Next action
affected-tests-test-execution-not-started The build succeeded, but the current build attempt has no test execution evidence: no partition started. Fix the first CI error before the test step (for example, environment provisioning), then run the workflow again.
affected-tests-partition-coverage-incomplete Test execution started, but some partitions have no terminal result or were cancelled (for example, a timeout). With a failed build, it keeps its previous meaning next to affected-tests-build-incomplete. Find the interruption, then build, test and finalize again.
affected-tests-partition-failed At least one partition ran and failed. Fix the failing tests or confirm they are transient, then build, test and finalize again.

Finalization closes the build attempt: the test stage rejects a finalized state, so the recovery is a new attempt that executes every partition, including missing ones.

A failed and an incomplete partition can be reported together. Test and finalization diagnoses add a Partition coverage line with the succeeded, failed and missing partitions. It is a diagnostic view: the receipt's CompletedPartitions still counts only successful execution receipts. An incomplete build is diagnosed before the tests it prevented. CI can pass --upstream-failure "<step name>" to name the step that failed between build and test; when tests never started, the diagnosis names it and the receipt records it as UpstreamFailure. Any other outcome ignores it. It is caller-supplied context only: it never changes the status or the reason codes, and a blank value is ignored.

The workflow resolves refs to immutable commits and requires the requested head to equal the checked-out HEAD before it can mint execution evidence. Build and test recheck that invariant, and the terminal etp.affected-tests-workflow.v2 receipt records the tested commit, tree SHA, whether the workspace was dirty at planning time, artifact sizes, and SHA-256 hashes. A trusted publisher can therefore reject dirty local evidence while using the same command surface as CI. Eventual fields such as a live pull request merge_commit_sha are not execution authority.

When post-build refinement adds test projects or required build roots, the workflow builds those additional roots and their dependency closure before binding prebuilt modules. Supplemental builds reuse the selected build's output root, configuration, MSBuild properties and private compiler server, with separate graph/log/receipt evidence. New bootstrap roots run first in the bootstrap output root. The full refined selection is retained even if a supplemental build fails; failure prevents test execution and green publication. Supplemental receipts participate in terminal artifact hashes, build timings and checkpoint provenance rather than replacing the initial build receipt.

Timing is measured from native plan entry to the observation immediately before finalize writes its receipt. StartedAtUtc, CompletedAtUtc, and ElapsedMilliseconds include preparation, waits between commands, retries, and inter-job transport within that interval; they exclude earlier runner queue/setup and later receipt publication. Cross-runner measurements require synchronized UTC clocks; a backwards interval fails instead of silently reporting zero. Finalizing again is a new observation, not an immutable first-completion timestamp. PlanningMilliseconds now measures the whole preparation/planning invocation with a monotonic stopwatch. BuildMilliseconds sums current sequential build receipts; TestMilliseconds is the longest current test execution receipt, not the elapsed matrix interval. Their RecordedStageMilliseconds sum is diagnostic, never total walltime or a measured critical path. summary.md makes these boundaries explicit. Use GitHub's workflow timestamps separately for total CI time.

The additive ExecutionMetrics object in terminal receipt v2 and its Markdown summary use the effective post-build selection/partitions, not the initial plan. They distinguish semantic method/class/project scopes from whole-project commands introduced by economic coalescing. Build-root counts are per materialized stage, not a distinct union; unknown provided-graph counts remain unavailable. Execution attempts include retries; attempts with command evidence count the detailed command records, not descendant processes or exceptional launches without a recorded command. Maximum observed concurrency is per receipt, not an inferred global matrix peak. Weight accounting exposes historical milliseconds, explicit default weights, startup milliseconds and the accounting/component/fragment/partition totals; generic default weights are not relabeled as milliseconds. Missing evidence remains null (unavailable in the summary). These observations do not change selection or seal authority. Older terminal v2 receipts without the additive object carry no execution-metrics evidence.

workflow affected-tests plan prints etp affected-tests-plan event="phase-end" lines as ref-resolution, input-capture, prepare, test-inventory, plan-resolution, partitioning, plan-publication, and state-persistence complete. Prepare reports cacheHit/fastCacheHit and its changedFilesMilliseconds, projectGraphMilliseconds, ownershipMilliseconds (including any BASE source-ownership proof) and cacheKeyMilliseconds sub-steps (zero on a fast-cache hit); a partial restore receipt adds baseline-partial-reuse after prepare, with reusedProjects, invalidatedProjects, graphInputChanges, retainedCarriers and removedCarriers; inventory reports projects, probed, cacheHits, cacheMisses, and parallelism. Planner StageTimings appear as nested stage.<name> details on resolution/partitioning, not additional wall time. The final event="plan-end" reports elapsed time, phase count, recorded phase sum, selected tests, partitions, impact mode, and analysis depth. It also reports the plan's input identity: inputsSha256 hashes declared inputs (tool version, commits and tree, uncommitted working-tree paths and contents (including the HEAD and contents of dirty submodules or nested repositories), request options with a workspace-relative scope, case-normalized MSBuild property names, and workspace-relative property values, change fingerprint, prepare's PortableGraphFingerprint of every selection-relevant evaluated graph field with workspace and MSBuild toolset paths made relative (fast-cached prepare manifests without it are reevaluated), input and data-exception policies, effective budgets, baseline/overlay/checkpoint manifests, and the captured test inventory, duration baseline, and partition settings the planner reads). The working-tree identity excludes untracked planner outputs (run root, .etp, cache, summary and GitHub output files; never a root at or above the workspace, and never tracked changes) and is recomputed after planning; a workspace that changed meanwhile fails the plan. effectiveInputsSha256 adds cacheState (per-stage cache hits, misses, and writes; prepare reports fast-hit or evaluated; --no-cache reports planner:disabled while prepare keeps its fast cache) and budgetOutcome (within-budget, or the exceeded budgets, with [wall-clock] on elapsed-time budgets); selectionSha256 hashes the ordered initial selection ids; baseline is the baseline manifest SHA-256 or none. Absolute paths are excluded, so runners with different work directories remain comparable. Equal effective inputs must yield equal selections; equal inputsSha256 with different selections points to cache reuse or a budget outcome. The same identity, with the hashed Inputs lines, is stored as EffectivePlanInputs in workflow state and terminal receipt, and both plan/summary.md and the final summary.md render an Effective plan inputs section. PlanPhaseTimings is additive in workflow state and terminal receipt; finalize renders the Plan phase timings table in summary.md. Old state without this field still finalizes, showing unavailable timings. State persistence excludes the final timing snapshot write. workflow affected-tests run sends phase events to stderr, leaving stdout as receipt JSON.

The final summary.md formats elapsed times as milliseconds below one second, seconds below one minute, and minutes/hours for longer stages. The receipt and structured console events retain exact millisecond values for automation. The build table separates MSBuild time from ETP overhead; overhead is execution cost, not a saving. When a canonical duration baseline is supplied, finalization also compares its cumulative historical test durations with the effective selection after build refinement. It reports estimated test work avoided only when every uncoalesced method scope has matching owner-specific case-duration evidence, all historical observations belong to tests in the complete current inventory, and each scope maps to one execution variant. Class/project scopes, coalesced scopes, multi-target commands, stale or unmatched observations, failed runs, missing baselines, and historical totals that disagree with their test observations yield an unavailable estimate. This is cumulative test work, not measured GitHub Actions wall time; a baseline from a complete run is needed for the historical total to represent the full suite. Inspect the same baseline with etp durations summarize --input <path>.

When no test inventory is supplied, project identity probes run concurrently (default clamp(Environment.ProcessorCount, 1, 8)). Set ETP_INVENTORY_PROBE_PARALLELISM=1 for sequential evaluation, or a positive integer (capped at 64) to override concurrency; invalid values use the default. Results retain deterministic project order. Per-project MessagePack cache entries in <cache-directory>/test-inventory are keyed by what selects the evaluation before it runs: the tool version, exact probe arguments (configuration and full project path), the SDK that dotnet --version resolves in the project directory plus the nearest global.json, the SDK-resolution environment variables shared with prepare's fast cache (PATH, DOTNET_ROOT*, MSBuild*) plus the MSBUILD_EXE_PATH restored for child probes, the presence and content of every NuGet configuration file from the project directory up to the root, and the workspace-relative imports (with content hashes) that prepare's evaluation found, so files a wildcard import newly matches invalidate once prepare is regenerated. A cache miss runs a single MSBuild evaluation: -getProperty returns the identity while a binary logger's ProjectImports=ZipFile side archive lists every imported file, so no second -preprocess process runs. The entry records and revalidates on lookup: the project file and every import (including SDK/package files) with its content hash; the Directory.Build.props, Directory.Build.targets, and Directory.Packages.props discovery locations from the project upward, stopping below the first existing file the evaluation did not import (MSBuild stopped at a nearer file that does not chain upward, so the configuration of an enclosing checkout cannot churn entries, while a chaining file keeps its parents covered); the nearest Directory.Build.rsp; and the names and contents of the files matching the SDK's restore-output wildcards under MSBuildProjectExtensionsPath. When that path lies below (or equals) the directory value of environment variables (such as a per-run build root), lookups also check the same relative location below each variable's current value and miss if one is gone. A discovery file that appears while the probe runs, or a failure of the instrumented probe, leaves the project uncached rather than failing or pinning a stale result. Entries deliberately ignore prepare's per-project CacheKey and environment variables outside the set above: an identity that depends on such a variable, on a file only tested with Exists(), or on a wildcard import outside the workspace needs --no-cache. A run-local concurrent memo hashes each distinct full path only once across projects, including missing-file results; subsequent evaluations start with a fresh memo. A missing or unrecognized imports archive bypasses caching; corrupt entries are misses. --no-cache bypasses this probe cache.

Use a stable runner-local --cache-directory to reuse prepare, analysis, and inventory entries across CI runs; the default <work-root>/.etp/cache is otherwise lost with a per-run work root. Concurrent jobs may share this cache: writers use unique same-directory files and atomic replacement (with bounded Windows contention retries), and unreadable entries are misses unless the analysis cache is explicitly required. Keep each job's --work-root/--run-root separate. Keys include workspace identity, so different checkout paths may share storage safely but need not achieve cache hits. Do not clean a shared cache while jobs are using it.

When workflow affected-tests plan receives neither --work-root nor --run-root, its run evidence and default cache use an external work root under the user's local application data directory (etp/workspaces/<workspace-hash>). The emitted run root remains the path to use for build, test, and finalize. An explicit work root or run root retains its current placement and is included in the workspace path scan if it lies inside the workspace.

The project-graph cache observes the workspace file and directory path set as well as project and import contents. Adding, deleting, or renaming a source file or an empty directory changes the graph key and triggers a fresh MSBuild ProjectGraph evaluation; editing an existing source keeps the graph entry reusable. Other planner caches have independent keys. The path-set scan includes physical files and directories in generated and Git-ignored trees, excluding Git metadata, the configured cache root (including all input-policy partitions) and the proven build-output directories described in Local shared cache and graph-cache exclusions, and hashes names rather than source contents. It needs Git only to prove those exclusions; without proof nothing is excluded. An unreadable path or linked file/directory bypasses the optional cache. Path comparisons follow Windows case-insensitive and Unix ordinal semantics. If the configured cache root equals or contains the workspace, optional graph caching is bypassed; --cache-required reports an invalid cache configuration. Safe partial graph reuse would require per-evaluation-context identity (effective globals, target framework, and SDK), observations of glob and missing-path conditions, unchanged reference/import topology, and differential proof against a fresh graph. The current cache uses full reevaluation when source membership changes. On an EAC checkout with about 11,500 tracked files, the path scan measured about 125 ms warm; a local tree with extensive build artifacts measured about 3.8 seconds warm. Clean CI checkouts are the intended operating profile. For a renamed runtime source, the current graph cannot prove every former owner, including linked or shared source inputs. ETP retains all projects for that tombstoned path until base-revision ownership evidence is available. For deleted or renamed sources, ETP can inspect the exact BASE commit in a temporary detached Git worktree. It fills the worktree from a byte-verified Git archive, initializes the index to the BASE tree, and requires a clean tracked status and exact tracked blob hashes before evaluating MSBuild ownership and former project consumers. Normal-build restore repeats those checks afterward. The temporary Git registration is removed after the attempt. A project that reads files inside .git cannot use this proof because linked worktrees represent .git differently; the manifest records base-git-metadata-layout-unproven. Evaluation-time source items, project references, imports and file-existence conditions must also be relocatable into that worktree. Paths that may still read the original workspace, including indirect or inactive MSBuild property expressions, retain the bound with base-nonrelocatable-input-evaluation. When normal build properties are declared, ETP requires restored NuGet imports and assets for both BASE and HEAD, and restores BASE only within its isolated staging budget. Missing imports, unsupported project types, restore outputs outside staging, incomplete target-framework evaluation, or target-time inputs retain the conservative bound with a BaseSourceOwnershipDiagnostics reason. ETP also retains the bound when evaluated items or imports can depend on build-stage properties that the proof did not reproduce. This includes ordinary SDK restore and output-layout imports when the exact build-stage context is unavailable. When earlier restore and target-time checks pass, the reason starts with base-build-stage-source-ownership-unproven. A valid BASE owner alone does not override that uncertainty. This proof is optional: a repository with legacy project types or external build-output roots may still retain the original full project bound. An exact BASE compiler snapshot may also carry base-ownership-inventory.v1.json. The native workflow passes its captured BASE snapshot separately from the current HEAD build-impact root. ETP accepts the inventory only with the restore admission record, exact BASE commit, repository scope, matching build configuration, a complete executable test-root and variant universe, and complete compiler source coverage. It maps every linked source owner and every possible local compiler-reference producer; uncertain assembly origins, uncovered variants, missing consumers or legacy snapshots keep the existing fallback. A BASE owner does not by itself certify current HEAD consumers; current build evidence must also cover that side before narrowing. Post-build refinement rechecks both admissions against the plan commits, build configuration and prepare identity before retiring a broad source scope. It retains scopes still reachable from the changed input, positive source selections and scopes without complete test inventory. Source ownership is independent of method-impact precision: a fully enumerated and validated compiler source inventory remains useful when a dynamic call or dispatch uncertainty prevents an exact method proof. Those method fallbacks still retain their conservative test selection. The ownership universe covers every executable test root and its dependencies; validated compiled projects outside that closure can contribute additional ownership evidence. An admitted non-test project without a carrier outside that closure is recorded explicitly and does not establish negative ownership for initial build selection. BaseSourceOwnershipDiagnostics records native-base-source-ownership for a certified input and a specific native-base-ownership-* reason otherwise. Inspect those reasons with etp inspect-replay --archive <native-replay.zip>. When admitted BASE/HEAD inventories have different build-configuration identities, a candidate can instead carry evaluated-base-source-ownership: the planner evaluated the exact staged BASE tree under the requested HEAD properties, with restore/import, target-time, omitted-project and path-safety checks. Post-build refinement independently repeats that bounded proof only when the candidate removed a deleted source's project/input pair. It checks the recomputed owners and former consumers against the candidate before retiring a broad scope. The repeat uses at most the remainder of the lesser of 90 seconds and the request's semantic time budget; expiry or any failed proof keeps the source scope. The staged route is opt-in (ownershipStagedBaseProof / ETERNET_BUILD_OWNERSHIP_STAGED_BASE_PROOF, default off; see Eternet.Build.Sessions): SDK projects rarely pass its relocation and target-time checks, while each attempt stages and evaluates the whole BASE tree in prepare, plan resolution and the post-build candidate replan. Disabled, each such source records base-source-ownership-staged-proof-disabled and keeps its conservative scope; native compiled ownership is unaffected. Neither proof runs when a changed global build file already keeps every project in scope (base-source-ownership-skipped-global-build-input), and a BASE tree tracking a non-C# project fails the staged restore check (base-restore-unsupported-project) from its Git listing, before staging. This route does not make build outputs reusable across configuration hashes. The HEAD inventory is captured after an unscoped full-impact build only when prepare deletes or renames a .cs, .fs or .vb source and post-build refinement will run; the compiled-ownership-capture build phase records why it was skipped otherwise, or its index-load, producer and admission times. A capture writes impact-index.compiled-ownership-attempt.json beside the build-impact root. This diagnostic receipt gives the BASE and HEAD commits, variant/source/root counts, elapsed time, status and reason codes for an unavailable HEAD inventory (including a legacy BASE without admission). It is not an ownership admission; only the separately validated current inventory and its evidence hashes can authorize narrowing. An existing runtime source absent from every evaluated input also retains all projects in scope: directory proximity cannot rule out a linked or conditional consumer elsewhere. A source with complete evaluated owner and consumer evidence can use the narrower bound.

Scope and limits of compiled ownership:

  • Native ownership applies only to deleted or renamed indexed sources (.cs, .fs, .vb). Other changes never read the inventories.
  • Proof narrows which projects are affected, not test precision: proven owners and their reverse consumers keep whole-project bounds (base-source-closure-fallback), so a deletion never gets method-level selection.
  • Inventory admission is all-or-nothing per run: one unusable carrier, variant or evidence hash leaves the whole HEAD inventory unavailable and every deleted source on its fallback.
  • Repository files that tests read at runtime without a declared consumer are not covered by compiler ownership (data-input-no-known-consumer).

Local shared cache and graph-cache exclusions

The local developer loop (several worktrees or clones of one repository on one machine) can reuse planner analysis across workspaces, and keeps the project-graph key stable while builds write into the tree. None of this changes the selection: a cache miss, a rejected entry or a disabled option recomputes the same result.

Shared analysis cache. --shared-cache-root <directory> (on etp plan and etp workflow affected-tests plan/run) or the ETP_SHARED_CACHE_ROOT environment variable names a directory outside every worktree of the repository (an error otherwise; if Git cannot report the worktree boundaries the shared cache is disabled instead). The option enables the persistent cache by itself; the environment variable only adds sharing to a run whose cache is already enabled (--cache-directory, --cache-required) and never enables it. Only two certified, path-independent stages are shared: symbol-reference-index-project and symbol-impact-graph. Every other stage stays in the local cache. Shared entries are content-addressed and keyed by the repository identity (root commits plus the normalized origin URL, never a workspace path), the tool identity, analysis depth, input-policy partition, evaluation properties and input hashes, so a different repository, tool build or MSBuild property set never hits. A shared hit is returned before the local entry; a rejected or corrupt shared entry is a shared-miss (also under --cache-required, where the local entry decides) and is replaced by the next write. Writes are atomic and first-writer-wins; a value that contains the workspace root is refused (shared-write-refused) and an unwritable root only loses reuse (shared-write-failed). The workflow receipt records the resolved root, the shared stages and the shared hits and misses. There is no eviction: prune the directory outside ETP.

Graph-cache key v2. The project-graph key states its item-path membership as workspace-item-paths:v2:<path-set hash>:<exclusion hash>. The hash of the excluded directories is part of the key, so a graph cached under one exclusion set is never reused under another. Project and import contents still identify the evaluation rules; editing an existing source keeps the entry, touching a .csproj/.props/.targets file or adding, deleting or renaming a path outside the exclusions misses.

Evaluated-exclusion manifest and ETP_GRAPH_CACHE_EXCLUSIONS. Build output under bin/ and obj/ would otherwise change the path-set hash on every build. A miss therefore evaluates the graph, derives the directories that MSBuild itself excludes from items (default-item excludes and the glob roots each project actually evaluates, expanded by MSBuild), keeps only those that git proves ignored and untracked, and stores them as a project-graph-exclusions manifest keyed by the evaluation rules. Later runs re-verify the manifest and drop the proven directories from the path scan; rule files observed inside an excluded directory (for example obj/project.assets.json) stay in the key. The graph is written only if the inventory did not change during evaluation (graph-write-skipped:membership-changed-during-evaluation). Missing metadata, unreadable or unproven evidence, an unexpandable include or a glob that reads an output directory yields no exclusions. Set ETP_GRAPH_CACHE_EXCLUSIONS=off to restore the previous full-inventory key.

Work-root cleanup. etp local gc [--dry-run] [--root <directory>] removes empty ETP work roots: directories under the default work-root storage (<LocalApplicationData>/etp/workspaces, and the equivalent under the temporary directory) that contain no files at all. There is no age-based retention: a root holding any file (run evidence, a lease, an artifact) or a linked directory is kept, and one that a concurrent run repopulates wins. --dry-run lists what would be removed without deleting. --root must be that storage directory, lie below it, or be a work root with a valid .etp-local-work-root marker (drive roots, the user profile and ancestors of the recorded workspace are refused). workflow affected-tests plan creates the run root only after the refs resolve and removes the directories it created if it fails before writing evidence, so failed plans do not leave empty roots.

Local incremental builds (--local). workflow affected-tests run and build accept the opt-in --local (alias for --build-mode local-incremental). The selected build then writes into a stable, ETP-owned root instead of a run-private one, so MSBuild skips CoreCompile for projects whose inputs did not change; the run root keeps only evidence (about 70 MB instead of about 5 GB). The default isolated mode, its receipts and its state are unchanged, and local evidence is never certification: green-tree evidence, checkpoint and snapshot publication and duration export refuse it (local-incremental-not-certifiable), including copied test/** receipts, which carry the local marker themselves.

  • Root: <work-root>/local-build/<workspace-key>/<configuration> (when --run-root lies inside the workspace and no --work-root is given, the work root for this default is the per-user external work-root storage instead of the workspace; an explicit --work-root inside the workspace is still rejected), or an explicit --build-output-root that is empty or already ETP-owned and lies outside the workspace, the run root and the work root (in both directions; junctions, symlinks, 8.3 names and \?\/UNC spellings are resolved or rejected before any check). Roots and the .etp-local-work-root marker carry a magic value and the owning workspace.
  • Lease: one OS lock inside the root (.etp-lease.lock) is held for the whole process. --build-lease-timeout (seconds, also on test and finalize) bounds the wait for a busy root and the wait prints waiting for lease held by <run> (<pid>). The split commands reserve the root for their run between processes (.etp-run-owner) until finalize, which releases it whatever the run's outcome. A reservation whose run is finalized, failed, deleted or whose build stage died is stale and replaced by the next run; a live one fails fast with local-build-root-busy and the finalize --run-root command to run. run holds the lock without a reservation of its own but still refuses a live reservation of another run. A run that selects no tests never leases.
  • Layout: the build stage detects EAC's EacAffectedTestBuildRoot redirect once and records it in the run state, so test and finalize never re-detect it. A redirect is a Directory.Build.props PropertyGroup that assigns BaseOutputPath or BaseIntermediateOutputPath a value referencing $(EacAffectedTestBuildRoot) (literal imports below the workspace are followed); defining or merely reading the property is not one, and the ArtifactsPath layout applies instead. Module binding looks only into the selected project's own output directory and only for projects of the built graph; anything else fails closed. A persistent root keeps outputs of renamed or removed projects, so when binding reports local-module-not-in-built-graph or found 0, evict the root with etp local gc --max-bytes 0 (default roots) or delete the explicit --build-output-root, then build again.
  • Retention: --keep-runs N (default 3 for run --local) removes older sibling runs after a green run, but only runs whose own typed state proves that they are finalized runs of exactly that directory, without .keep, links or nested ETP roots. Eligible runs are filtered first, then the newest N are kept; failures are warnings.
  • Budget: etp local gc [--max-bytes N] [--dry-run] evicts least-recently-used stable roots under <work-root>/local-build/<key>/<configuration> (exact depth, never through a link) until the work root fits (default 16 GiB). Roots that are leased, reserved by a live run, contain links or nested ETP roots are skipped. --dry-run writes nothing and lists exactly what apply would delete; failed evictions are counted, reported and make the command exit 2. A root is evicted in place, never renamed: under its lease the evictor writes an .etp-evicting intent file (holding the marker's content), deletes the ownership marker first and then the rest, and only then the intent file, the lock file and the empty directory (the last two best-effort: a failure leaves an empty directory, never contents). While the intent file exists the contents are untrusted, so whoever next holds the lease (a build, or the next collection) finishes the removal and treats the root as new; a root with nothing but its lock file is gone (local-build-root-missing, and the next resolution marks it again); only a non-empty unmarked directory is refused as foreign. An acquirer therefore sees the complete root, a clean new one, or the root gone, never a half-deleted tree. A root whose contents cannot be fully deleted (read-only or locked file) is reported as failed, its remaining bytes stay in the before/after totals, the command exits 2 and the next collection finds it again through its intent file. Explicit roots are caller-owned and never collected. The leased root is laid out by project path (bin/<sha256 of the full project path>, via an etp-local-layout.props import, the layout CI shared builds use) so two projects with the same file name never share output directories.
  • Rejected at parse time: --local with an explicit --build-mode isolated, with --complete-snapshot or in a CI environment (CI, GITHUB_ACTIONS, TF_BUILD); --build-output-root or --build-lease-timeout without --local; negative --keep-runs or --build-lease-timeout. --local also rejects every caller-owned MSBuild output property (ArtifactsPath, OutputPath, BaseOutputPath, BaseIntermediateOutputPath, UseArtifactsOutput, EacAffectedTestBuildRoot, in any -p://property: spelling, alone or in A=1;B=2).

Local CI baseline import (--local-baseline). A local run can start from the compiler baseline CI already built for the base commit. etp local import-baseline --evidence-zip <native-plan.zip> --workspace <repo> [--expected-commit <sha>] [--shared-cache-root <dir>] [--output <json>] validates and caches it; workflow affected-tests plan|run --local --local-baseline <imported root or its local-baseline-import.json> binds it. An imported baseline is local evidence and is never certifiable, whatever the run selected: --local-baseline requires --local (rejected at parse time on plan and run), the state and receipt record localBaseline and the local-baseline mode, and green-tree, checkpoint (including the no-op), snapshot and duration publication refuse them with local-baseline-not-certifiable, also for a run that selected no tests and never built.

  • Import: the ZIP is hashed, then only what its snapshot manifest lists (plus the manifest, baseline-restore.json and the optional inputs/test-durations.mpack) is extracted, in that order: the manifest is read first and its declared lengths are hard bounds (the streamed bytes must equal the declared length), with entry, byte and 100:1 compression-ratio bounds. Entry names must be plain relative paths: no : (alternate data streams, drives), ?, *, ", <, >, |, \, control characters, trailing dot or space, device names (NUL, COM1, ...), rooted or .. segments, and no case-insensitive duplicates. Extraction goes to a staging directory that is published by rename; a concurrent importer that wins is verified and adopted. Failures (corrupt ZIP, wrong commit, bounds) exit 2 with a one-line message.
  • Verification: the restore receipt's commit must equal --expected-commit (or the plan base); a receipt whose status is not restored is reported separately. Manifest hashes, the carrier hashes and count and the durations schema are re-derived from the bytes. The import receipt (etp.local-baseline-import.v1) records the ZIP SHA-256, the commit, an input fingerprint (the manifest hash and the carrier inventory), the durations hash and generatedAt, the tool version and an advisory externalSources report.
  • Cache: <work-root>/.etp/local-baselines/<tool-key>/<zip-sha256> (or under --shared-cache-root), keyed by the tool's informational version. A cache hit requires the import receipt to exist and parse, and recomputes the fingerprint, the durations hash and the ZIP identity; a missing or mismatching receipt fails with the directory to delete. The rebased local-restore.json is derived again when it is missing, altered or names another root, so a moved cache keeps working. Imported baselines are never garbage-collected: etp local gc does not touch them (its budget covers stable build roots only). Delete a <zip-sha256> directory to reclaim it (about 60 MB each); a --purge-baselines option for etp local gc is a follow-up.
  • Precedence: an explicit --build-impact-root wins; the import is then neither validated nor recorded (a local-baseline-unused event says so). Durations: an explicit --test-duration-baseline, then --duration-baseline-store-root, then the imported durations. Imported CI durations only partition and order the local run (PartitionDurationBaselinePath): the economic preflight, the selection budgets and the pre-build decision never see them, so what runs is decided as without an import. They are used only while younger than --duration-baseline-max-age-days (default 8); older ones are ignored with a local-baseline-durations-ignored warning while the import stays valid. The recorded localBaseline.durationsUse is partition-only, stale-ignored, superseded or absent.
  • External package sources: CI carriers record source files that package targets inject from the CI machine's NuGet folder (for example C:\NuGetFallback\packages\xunit.v3.core.mtp-v2\3.2.2\...\DefaultRunnerReporters.cs). When such a recorded path is absent locally, the reader maps <id>/<version>/<tail> (separators and .. resolved logically) onto the evaluated NuGetPackageRoot, RestorePackagesPath, the NuGetPackageRoot property, NUGET_PACKAGES and the user-profile packages folder, trying each root (and each packages folder of the path) until one holds a file that hashes to the recorded hash; blank, relative and duplicate roots are skipped, and an unrepresentable path rejects the shard. A recorded path that exists is used as recorded and no other root is probed, so CI behavior is unchanged. A changed, missing or differently versioned package never binds. The import's externalSources uses the same policy and is only true at import time: on a machine that never restored the package the first run stays retained and later runs adopt.

workflow affected-tests build is observable from the console without downloading the run root. As each subphase finishes it prints one structured line to stdout, in the same key="json-string" style as the etp test-partition events: etp affected-tests-build event="phase-end" phase="<name>" outcome="<outcome>" elapsedMilliseconds="<n>" .... Phases include state-validation, baseline-validation, prepare-rebase, prebuild-decision, prebuild-producer-build, prebuild-overlay-materialization, prebuild-refinement, bootstrap-build, selected-build, overlay-materialization, snapshot-completion, post-build-refinement, supplemental-bootstrap-build, supplemental-build, module-binding, receipt-publication and compiler-server-shutdown. Build phases carry the project count, maxNodeCount, exit codes, and msbuildMilliseconds (restore + build process wall time from the stage receipt) separately from ETP's own bookkeeping. baseline-validation splits its time into baselineIntegrityMilliseconds (re-hashing every owned baseline file against the plan's manifest, with bounded parallelism), parentOverlayMilliseconds (reading and re-verifying a consumed parent checkpoint overlay) and otherValidationMilliseconds (plan receipt, data-input exceptions and input policy). A terminal event="build-end" line reports the total elapsed time, status, exit code, baseline and impact mode, build node count, pre/post-build refinement outcomes, whether refinement strictly narrowed the selection, projects built per stage, effective tests and partitions. The same timings are persisted additively in the workflow state (BuildPhaseTimings, BuildElapsedMilliseconds) and copied into the terminal receipt (BuildPhaseTimings, BuildPhaseElapsedMilliseconds), which finalize renders as the Build phase timings table in summary.md. Compiler-server shutdown happens after the state is persisted, so it appears only on the console. workflow affected-tests run writes the same events to stderr because its stdout carries the receipt JSON.

Every plan, build and test phase also writes etp affected-tests-{plan|build|test} event="phase-start" phase="<name>" startedAtUtc="<ISO-8601>" when it begins, before its phase-end, so a consumer can report which phase is running and for how long. workflow affected-tests test (and the test stage of run) reports the partition execution as two phases: preparation (the prerequisite build of the test modules) and execution. While execution runs, it writes event="phase-progress" phase="execution" completedFragments="<n>" failedFragments="<n>" totalFragments="<n>" elapsedMilliseconds="<n>" at most once per phaseProgressIntervalSeconds (build-session policy, default 15 s, ETERNET_BUILD_PHASE_PROGRESS_INTERVAL_SECONDS), and its phase-end carries the final counts. A fragment is counted once, by its first attempt; a retry does not count it again. post-build-refinement writes phase-progress lines with a step as each refinement step completes. The schema is additive: consumers that read only phase-end lines are unaffected.

Successful output remains limited to those phase lines and the terminal summary. When a build or partition fails, ETP additionally prints a compact failure diagnosis with the phase, command/project, exit code, retryability, evidence paths, and next action. Detailed process output remains in receipts and logs. An isolated MSB4181 is reported as inconclusive because it does not identify the underlying task error; use the referenced binlog to find the first actionable diagnostic before retrying.

Post-build refinement diagnostics and plan reuse

When compiler refinement retains a production project's broad scope because its evidence is incomplete, DiagnosticEvidence.ProductionProjects[].CompilerEvidence records the observed semantic, test-inventory and Razor-structure completeness. RazorInputs identifies each input and its concrete failure reason. These facts come from the index already read by the planner; diagnostics do not evaluate or read the project again. Receipts that lack this evidence keep the existing unverified status, including when their prepare identity does not match.

Checksum-bound Razor directive imports containing only C# @using directives do not require a component type structure. The shared source proof validates the exact input hash and parses the directives with Roslyn; code, markup and unsupported directives keep the conservative fallback. New origin sidecars mark those inputs with InputRole: "directive-import"; compatible older evidence can establish the same fact from the checksum-verified checkout. Compiler support methods such as Execute do not by themselves make an import a component. An ordinary incomplete component still blocks narrowing. Changing an import retains the owning project's conservative impact when its effect on components cannot be proved from the index.

Affected builds validate selected, required and bootstrap project paths against the analyzed workspace before invoking their build stages. Missing mandatory projects fail with the stage, project path, requirement kind and recorded partition-settings path. Mandatory roots are never silently omitted. This also applies to supplemental roots introduced by refinement.

post-build-refinement now includes additive stage.<name>="<milliseconds>" details on its phase-end line. These are also stored in the refinement receipt's StageTimings, the workflow's BuildPhaseTimings[].Details, and the terminal receipt/summary.md build-phase table. A directly requested refinement Markdown summary includes a separate stage table. Older receipts without StageTimings remain readable. The enclosing phase stopwatch and total wall-time boundary are unchanged.

Stage Meaning
candidate-planner Entire candidate CI-plan resolution, including partitioning/publication
candidate-plan-resolution, candidate-partitioning, candidate-plan-publication Candidate phase wall times
candidate-git-diff, candidate-msbuild-graph, candidate-candidate-test-resolution Changed-file discovery, project graph, and candidate resolution; zero when reused
candidate-conservative-plan-reused / candidate-fast-project-replanned Zero-duration marker identifying the chosen path
build-impact-root-current, build-impact-root-predecessor-0, ... Root inspection wall times; predecessors numbered newest first
build-impact-carrier-parse, build-impact-receipt-validation, build-impact-hashing, build-impact-project-evaluation, build-impact-assembly-impact-read Aggregate worker durations across shards/roots
build-impact-snapshot-load / build-impact-composed-snapshot-load Complete portable index read, including semantic shard materialization
build-impact-index-refine, build-impact-cost-estimate In-memory refinement/base-source lookup and cost estimation
evaluator, safety-union-partitioning Safety evaluation and optional reconciliation/repartitioning
receipt-write, effective-writes Initial decision receipt and effective selection/partition writes

Existing planner/partition pipeline timings are propagated as well. These measurements are nested, not additive wall time: worker totals can exceed the phase elapsed time, and receipt validation includes artifact hashing. Reused upstream timings have a reused-plan. prefix and describe the original plan, not work repeated after build. The final receipt timing-snapshot write is excluded from receipt-write; it and summary/ GitHub-output publication remain inside the enclosing phase. New diagnostic timings do not enter economic cost calculations or the existing refinement decision hash. Existing planner telemetry and its derived cost estimates remain observational.

Post-build refinement can feed the validated conservative selection directly into the compiler-index refiner, avoiding git discovery/project graph/candidate resolution. Reuse requires an unscoped FastProject manifest, matching prepare base/changed paths, unchanged input-policy hash, and no overlay or forced-full/compiler-refined selection. Input policy is reread before reuse; workspace/request checks, carrier/receipt validation, external inventory reconciliation, incomplete-inventory retention, safety-union evaluation, and forced-full rejection are still applied.

Replanning remains necessary for forced-full manifests (which lack changed-file/ownership evidence), semantic/scoped or already compiler-refined plans (not the original broad FastProject closure), overlays (which may use a different evidence base), or mismatched prepare/policy evidence. Workflow state now preserves AnalysisCacheDirectory from planning and passes it to pre/post-build refinement, so these paths can use the same persistent analysis cache. --no-cache stores no directory; older state without the field keeps the previous no-directory behavior. The shared inspection cache below is independent of this persistent cache.

Current and predecessor roots are inspected concurrently (up to four roots), while sharing a worker budget capped at min(Environment.ProcessorCount, 8). Composition still uses current, newest predecessor, then older predecessors, regardless of completion order. Invalid current evidence still masks older evidence for the entire project. Each worker retains its own MSBuild ProjectCollection; project evaluations are not shared. One read-local concurrent cache shares SHA/MVID values across shards and roots, keyed by normalized full path, algorithm/identity kind, file length, and last-write ticks. Concurrent requests compute each version once. Metadata is checked again after reading: a detected concurrent file change fails validation rather than publishing a stale cached value. Missing/failed reads do not poison subsequent lookups, and nothing survives the read. All existing source, build-input, carrier and assembly-impact hashes remain validated.

For a reproducible local comparison from the repository root:

dotnet build tools\Eternet.TestPlanner\tests\Eternet.TestPlanner.Tests\Eternet.TestPlanner.Tests.csproj -c Release --no-restore
dotnet test tools\Eternet.TestPlanner\tests\Eternet.TestPlanner.Tests\Eternet.TestPlanner.Tests.csproj -c Release --no-build -- --filter-method '*Warm_candidate_path_benchmark*' --report-trx --results-directory tools\Eternet.TestPlanner\tests\Eternet.TestPlanner.Tests\bin\performance-results

The test records samples in TRX standard output: one SDK test-project fixture with an accepted synthetic portable carrier, one effective test and command fragment, one warmup per path, and six alternating pairs with no persistent cache. Both paths use the same reader and partitioner, isolating the cost of replanning versus conservative-plan reuse; this is not a measurement of the full 60-project CI workload or an estimate of the reported 42-second phase. A local Windows run on 2026-09-18 measured means of 383.78 ms replanning versus 232.91 ms reusing (six samples each). Replan samples were 368.89, 409.76, 432.48, 370.46, 347.83, 373.26 ms; reuse samples were 193.44, 176.67, 316.41, 253.82, 244.62, 212.48 ms. These are fixture observations, not performance thresholds asserted by the tests.

Migration: v2 replaces the misleading native workflow CriticalPathMilliseconds field with RecordedStageMilliseconds and mandatory timing evidence. Old v1 run states must be replanned in a fresh run root; the CLI does not fabricate timestamps or alias the old field. Historical build/partition receipts keep their own schemas. State v3 additionally requires an owned baseline and integrity binding. Old v2 states must also be replanned; absolute baseline paths are not silently rebound to another runner or migrated by guessing which snapshot was used. The terminal timing receipt remains v2. Native parent consumption requires a freshly materialized etp.build-impact-ephemeral-overlay.v3 (or v2 during the fingerprint transition); v1 ephemeral files contain no source fragments and must be materialized again rather than relabeled or widened through a legacy fallback.

Build materializes the selected graph, uses the SDK artifacts layout for project-isolated bin and obj, enables current compiler carriers, applies post-build narrowing, and binds partition fragments to the exact prebuilt modules. A ProjectReference that changes global properties (for example AdditionalProperties="Name=Value" or GlobalPropertiesToRemove) makes MSBuild build the referenced project, and everything below it, once more under the changed set. Neither the artifacts layout nor the default layout puts global properties in paths, so both configurations would write the same obj and bin files and their concurrent compiles fail with CS2012. When ETP owns the output paths it scans the workspace's literal ProjectReference metadata (AdditionalProperties, Properties, the Set* forms, GlobalPropertiesToRemove and UndefineProperties) for the property names involved. Evaluation cannot enumerate global properties, so the names are needed. A CustomAfterMicrosoftCommonProps import then probes which of those names each configuration receives as global properties, with their exact values. MSBuild computes the requested projects' key from the build's own final arguments, so ETP parses no arguments. Every configuration with another key moves its compile outputs below <artifacts>/forks/<key>/. The import runs after Directory.Build.props, the SDK's artifacts naming and the restore imports, so requested configurations keep their paths (receipts, module binding and carriers are unchanged) and a fork still reads its project's real restore outputs. A caller that passes the same property globally (as EAC's CI does) creates no fork, and environment values never fork. Configuration, platform, target framework, runtime identifier, the solution-provided properties and reserved MSBuild* properties are not watched, and names produced by expressions or targets are not seen. Caller-owned output paths or a caller CustomAfterMicrosoftCommonProps (argument or environment) leave the layout to the caller. The reason reference-fork-isolation records the isolation. A project built both as requested and as a fork shares one carrier, receipt and inputs-cache destination: the requested configuration owns it (a fork writes it only while no receipt exists and never overwrites one). Build-output reuse stores and materializes only requested configurations: a receipt or entry rooted below forks/<key>/ is skipped (publish-skipped-fork-instance) or refused. A retained forced-full plan is terminally rejected when post-build evidence is missing or unusable unless the caller explicitly passes --allow-retained-forced-full.

Full-carrier builds retain the validated planning snapshot for projects not rebuilt in the current phase. Pre-build refinement combines that baseline with fresh producer carriers; post-build refinement combines baseline, producer, and selected-build carriers in that precedence order. Every input is revalidated in the current workspace and restore context. A newer project's complete set of target-framework shards replaces the older project, even when the newer evidence is unusable. Unidentifiable, foreign, or empty supplied roots fail composition instead of resurrecting older authority. This is in-memory composition, not snapshot publication. Planning captures the baseline once; composition does not duplicate it for each phase. The reason build-impact-index-predecessor-composed and timing stage build-impact-composed-snapshot-load expose this operation. BuildImpactBaseRoot is relative to the run root, and its manifest binding prevents accidental baseline mutation between jobs.

For standalone ci refine-post-build, repeat --build-impact-predecessor-root <root> in oldest-to-newest order; --build-impact-root is always the newest phase. Full-carrier composition cannot be combined with --build-impact-overlay: delta overlays retain their own explicit verification and lineage protocol. No selection guard is relaxed.

--work-root allows the run, copied inventory/settings, and build outputs to live outside the checkout (for example on a runner RAM disk). The test stage uses that same explicit run root; project inputs still belong to the workspace. In-process MSBuild evaluation must not change the SDK of child dotnet commands: ETP restores the caller's original SDK environment for graph materialization, build, and test execution while retaining Locator's environment for analysis.

Repository-specific build prerequisites are declarative partition settings, not hard-coded ETP project names:

{
  "buildMaxNodeCount": 8,
  "enablePreBuildRefinement": true,
  "buildRequirements": [
    {
      "projects": ["tests/Product.Tests/Product.Tests.csproj"],
      "requiredProjects": ["tests/Product.Fixture/Product.Fixture.csproj"],
      "bootstrapProjects": ["src/Product.BuildTasks/Product.BuildTasks.csproj"]
    }
  ]
}

Rules can match project, projects, or projectGlob. Matching prerequisites survive fragment coalescing. Build parallelism defaults to one and is capped by the host processor count; bootstrap builds are serialized. Before test builds, ETP may build affected producers and refine the selection when their dependency closure is reusable by the conservative build. Complete duration evidence must predict net savings; otherwise bounded structural limits apply. Missing graph evidence or global/forced-full changes bypass this optimization. The recorded prebuild decision and refinement receipt explain whether it narrowed anything; enabling prebuild is not itself evidence of avoided builds.

Portable carrier inspection (build-index) and snapshot publication accept the same etp.prepare.v2 content in .json, .mpack, or .msgpack form. Carrier inspection reads a typed projection directly from the canonical payload; neither command writes a JSON sidecar or changes the supplied prepare file. Required coverage, schema and transported-workspace identity checks apply equally to both encodings. Existing JSON prepare inputs remain supported; migrating a workflow to MessagePack does not require weakening validation or duplicating files.

prepare, plan, selection, partition, checkpoint and overlay payloads are persisted once as compressed MessagePack. MSBuild reads prepare and parent checkpoint projections directly, without expanding them into a JSON bridge. plan and build can append their small resolved refs, selection decision, and minimal partition matrix directly to GitHub Actions' $GITHUB_OUTPUT, including the runner-owned path outside the checkout. finalize requires complete green partition coverage and writes one canonical durations/etp-test-durations.mpack payload when tests executed. It does not emit parallel large JSON copies. JSON is reserved for the small state, manifests, and receipts. Consumers remain responsible only for checkout, credentials, runner provisioning, transport, retention, and repository/event policy.

Checkpoint objects contain checkpoint.mpack, addressed by the hash of its exact binary bytes; the logical checkpoint fingerprint still uses canonical semantic content. Owned parent checkpoints are stored as inputs/build-impact-parent-checkpoint.mpack. Overlays use inputs/build-impact-overlay.mpack and build/current-build-impact-overlay.mpack: their binary envelope is [tag, payload], where 0 is certified and 1 is ephemeral. The tag selects the concrete type in one decompression pass and must agree with its schema. Unknown tags, corrupt bytes and identity mismatches fail closed. CLI callers may request explicit JSON for inspection; native runs never emit both encodings. Certified prepare inputs are copied byte-for-byte in their original explicit encoding because the overlay binds the prepare's raw hash.

Rollout requires publishing the tool and Build package together, then updating consumer checkpoint transport paths and the central version/commit pin. Existing JSON checkpoint objects are not renamed or rewritten in place: their content address would change. Automatic object lookup expects the new canonical filename; an unavailable prior object requires a fresh validated checkpoint. There is no filename probing or JSON-sidecar fallback.

Native build records test modules inside the run root as relative paths in the canonical partition payload. Transport the entire run root to the next runner; test resolves those modules against its current run root without a PowerShell rebase. An explicit --module-root is authoritative for relative test-module paths: a missing transported module fails, even if a same-named workspace module exists. Path traversal outside that root is rejected. Custom build outputs outside the run root retain absolute, host-bound paths and must remain accessible under the workspace or an explicit module root; copying only the run root does not transport those external outputs. Rebuild older native run roots to obtain the portable relative-path manifest; no legacy path-rewriting fallback is applied.

Partition execution

Declared RequiredBuildProjects are conventional runtime payloads: ETP builds each distinct project/configuration with implicit restore before the first fragment that needs it, for both execution strategies. Preparation is shared only within one execution invocation; successes and failures are reused across its fragments, with no implicit retry or persistent cache. Conventional builds are serialized because different roots can share obj/bin dependencies. The shared prerequisite bundle restores every required project in one NuGet graph, as a solution restore does, so a reference shared by several roots is restored once even when the bundle builds roots in parallel. A root that does not import NuGet's targets runs its own Restore target afterwards. Like the build stage's restore of graph/selected.slnx, the graph restore does not run a root's own Restore target, so BeforeTargets/AfterTargets="Restore" hooks in required projects are not part of the restore contract. Consumers must treat these prepared payloads as immutable during the invocation. prebuilt-no-build with no requirements starts no prerequisite build, and build-before-test leaves its own project's restore/build to dotnet test.

Partition concurrency and test-runner concurrency are independent. For serial local measurements with xUnit v3, plan with --partition-count 1 and a --partition-settings JSON containing "MtpArguments": ["--max-threads", "1"], then execute with --max-parallelism 1. The runner arguments are preserved in each emitted command fragment and execution receipt. A single partition alone does not override the runner's thread setting. Keep these measurement settings separate from CI settings; other runners require their own supported options.

Build-time refinement also requires the consumer to load the Eternet.TestPlanner.Build generator and MSBuild targets. Setting EternetTestPlannerImpactIndexEnabled=true alone does not install or import them. If a successful build reports post-build-impact-index-missing, inspect the consumer's package/import configuration and the run's build/impact-index before changing selection budgets. Without those producer artifacts ETP retains the conservative selection; this is not evidence of semantic refinement.

The Build package's EternetTestPlannerMaterializeImpactIndex target is incremental: it declares MSBuild Inputs (generator carrier, peer generated sources, intermediate assembly/PDB, reference assemblies, prepare and assembly-reference manifests, EAC diagnostic paths, the task assembly and a scalar fingerprint file Eternet.TestPlanner.ImpactIndex.inputs.cache) and Outputs (copied carrier, peer-sources manifest, Eternet.TestPlanner.BuildReceipt.json, Eternet.TestPlanner.AssemblyImpact.json.gz, or the delta fragment). ETP's test-phase preparation build is therefore a true no-op for already-built projects: the receipt and assembly-impact payload are left untouched instead of being recomputed. The fingerprint file is rewritten only when a scalar input (configuration, TFM, RID, SDK version, diff base, SourceRevisionId, delta parent metadata, reference list, …) actually changes, which reruns the target without forcing a recompile. Set EternetTestPlannerIncrementalImpactIndex=false to force the target to run on every build; outputs are identical either way.

Empty and executable CI plans both emit the canonical etp.partitions.v1 partition schema. Consumers must preserve it rather than reconstructing the payload. Regenerate empty receipts from older releases that emitted the retired etp.test-selection-partition.v1 label; no schema-rewriting adapter is required.

ETP owns execution of the partition manifest it produced. The same command supports local bounded parallel execution and a matrix runner's exact single partition, and emits etp.partition-execution.v2 with per-fragment logs and fail-closed aggregate status:

etp execute-test-partitions --workspace . --partition-manifest artifacts/test-partitions.mpack --max-parallelism 3
etp execute-test-partitions --workspace . --partition-manifest artifacts/test-partitions.mpack --partition 1

Every run has an isolated run/result root. MTP arguments are forwarded only after --; path roots are validated inside the workspace/run root, and cancellation terminates only the child process tree owned by that invocation. Consumers may transport the receipt and logs but do not reimplement partition scheduling, strategy resolution, or aggregate success policy.

The receipt's SchedulingMode states how slots were assigned. An exact --partition lane uses partition-sequential: its fragments run one at a time, exclusive-host fragments first. All-partitions execution uses fragment-pool and adds the fragment-pool-scheduling reason code: every selected fragment enters one ready queue ordered by exclusive-host first, then descending EstimatedWeight, then manifest order, and --max-parallelism bounds concurrent fragments instead of partitions. Static partition weights are only estimates, so this drains a skewed manifest at roughly max(longest fragment, total / slots) instead of the longest partition. Fragments sharing an ExclusiveGroup still never overlap: a blocked group defers the fragment and the slot takes the next eligible one. Exclusive-host fragments keep the host gate. Per-partition results, ordinals, evidence directories, and partition-start/partition-end events keep the sequential layout; a partition's window spans its first queued fragment to its last completion.

Fail-fast

By default a failed fragment does not stop anything: every selected fragment runs so a red run is still complete evidence. --max-failures N (or --fail-fast, which means --max-failures 1) opts into stopping early on execute-test-partitions, workflow affected-tests test and workflow affected-tests run:

etp workflow affected-tests test --workspace . --run-root artifacts/affected-run --max-failures 1

Once N fragments have failed (non-zero after MTP exit-code normalization; fragments the stop interrupts do not count), no new fragment is dequeued and running fragments are cancelled through the ordinary cancellation path, which kills each fragment's entire process tree (including MTP test hosts). The receipt's FailFast block records the threshold, failed and interrupted fragment counts, and every fragment that never started. The execution status is failed, the reason codes add execution-stopped-after-max-failures, interrupted fragments carry the outcome reason stopped-after-max-failures, and a partition keeps its own failure as its status. Partitions that never started stay absent, as for any partition that never got a slot. A fail-fast-stop event marks the moment the threshold was reached.

The threshold is counted across every partition of one invocation. All-partitions execution schedules every partition from one fragment pool, so a failure in one partition stops the others. Separate --partition N processes (a CI matrix) count independently; each stops only its own partition.

finalize reports a stopped run with affected-tests-stopped-after-max-failures and a "Stopped early" summary line instead of affected-tests-partition-coverage-incomplete, unless a missing partition was not requested by any invocation that stopped. Without a stop, finalize keeps its not-started, failed and incomplete classification unchanged. A stopped run is never certifiable: its status is failed, and green-tree and checkpoint publication reject it like any other failure. Without the option, receipts carry no FailFast block and execution is unchanged.

Fragment watchdog

A hung test process otherwise holds its slot, and the whole run, until the CI job times out. Every launched fragment is therefore watched, and the partition-settings block fragmentWatchdog (read by workflow affected-tests test) configures it:

"fragmentWatchdog": {
  "stallTimeoutMilliseconds": 600000,
  "stallCpuCoreThreshold": 0.01,
  "durationCeilingFactor": 3,
  "durationCeilingFloorMilliseconds": 600000
}
  • Stall detection is on by default (10 minutes, 0 disables it). A fragment stalls when, for the whole timeout, its stdout and stderr did not grow and its whole process tree used less than stallCpuCoreThreshold cores in every 15 s sampling interval. CPU comes from the fragment's job object (see below), so the detector is armed only on Windows with job-object-tree evidence and disarms if a query fails: the root dotnet test process idles while its test host works, so root CPU alone would terminate busy tests. MTP prints nothing while tests pass, so output alone is no signal either; CPU is. A silent fragment that keeps one core busy (a busy loop) is not a stall, and neither is an idle one that still writes output.
  • The duration ceiling is opt-in (durationCeilingFactor, at least 1). A fragment is terminated after max(factor x EstimatedWeight, durationCeilingFloorMilliseconds) (floor default 10 minutes), only when its estimate is a complete millisecond estimate (DurationEstimateIsComplete); unitless weights get no ceiling.

A triggered watchdog terminates the fragment's whole job (descendants whose parent already exited included) and the root's process tree. The fragment fails with exit code 124, outcome reason fragment-stalled or fragment-timed-out, a [etp watchdog] line in its stderr.log, and a Watchdog block with the trigger, threshold, inactive time, last output time, the last 40 cumulative tree-CPU samples and the last output lines. A fragment-watchdog event (carrying status="failed") is written when it happens, the receipt adds fragment-watchdog-stalled or fragment-watchdog-timed-out and a Watchdog block with the thresholds and counts, and finalize prints a Watchdog: line per terminated fragment. Like any failed fragment it fails its partition and the run, counts toward --max-failures, contributes no duration observation and can never certify.

The stall default is deliberately far from normal behaviour: the longest healthy whole-project fragments of recent green full-suite runs took 8.2 minutes in total, and a stall needs ten minutes without a single interval above 1% of one core. A process can idle legitimately while something outside its tree works (a container runtime pulling an image, a service it waits for), but not silently for ten minutes. On a loaded workstation, starting PowerShell processes sometimes idled for a few seconds (script scanning runs outside the tree), two orders of magnitude below the default.

The execution receipt exposes StageTimings for partition-manifest-load, prerequisite-preparation, and test-execution, plus CriticalPathMilliseconds as their sum. Preparation completes before fragment clocks and exclusive-host leases begin. Its duration remains in the total critical path without inflating fragment history or test parallel-capacity metrics. The native executor emits the preparation stage even when no prerequisite roots are needed.

Preparation builds the deduplicated union of required roots and build-before-test targets in one MSBuild session. --build-max-node-count controls the node budget and enables parallel builds when greater than one. Identical shared references retain MSBuild's session cache. Configuration and project-reference property contexts remain intact. Conventional test consumers wait for the entire bundle to finish. The run's preparation/ directory retains the generated project, exact command arguments, stdout, and stderr.

Parallel preparation requires distinct build contexts to have isolated outputs. ETP preserves reference properties and does not rewrite a repository's output layout. Before launching parallel preparation, ETP evaluates the reference graph and rejects distinct instances of the same project with shared output directories. The diagnostic names the project, output directory, and differing global-property names (not values). preparation/output-validation.txt records this check's elapsed time. It does not serialize builds or retry failures. Single-node preparation skips this extra evaluation. Fix conflicting project contexts at their source: use consistent reference properties for identical outputs, or distinct OutputPath and IntermediateOutputPath values for intentionally different outputs, as described in Microsoft's build race guidance. Isolate restore assets with BaseIntermediateOutputPath (set before SDK imports) when restore inputs also vary by context. --build-max-node-count 1 is an explicit workaround for a diagnosed collision; it is not required for shared references with identical properties. These settings do not change the run-private compiler server.

Version 2 also records the host ProcessorCount, configured partition limit, observed peak concurrency, UTC start/end windows for every partition and attempted fragment, and a single-attempt timeline. A failed attempt is classified as infrastructure only when process launch or local I/O proves it; a non-zero test-runner exit is unknown because the runner output may represent assertions, discovery, or infrastructure. ETP performs no implicit retries.

TotalPartitionBusyMilliseconds is the sum of partition windows. ParallelCapacityMilliseconds is the observed test-execution wall clock multiplied by the fragment slot count: min(MaxParallelism, RequestedPartitions.Count) in partition-sequential mode and min(MaxParallelism, fragment count) in fragment-pool mode. IdleParallelCapacityMilliseconds is its non-negative remainder after slot busy time, which is the partition window sum in partition-sequential mode and the fragment duration sum in fragment-pool mode because pooled partitions overlap. PartitionDurationBalancePercent is average partition duration divided by the longest partition duration (100 for an empty/zero-duration run). PeakConcurrentPartitions counts partitions that held at least one slot at the same time; PeakConcurrentFragments counts fragments executing at the same time after the host gate. These values diagnose scheduling imbalance; they do not claim CPU utilization.

These scheduling values describe the main schedule only. With --retry-failed-fragments the retry pass runs afterwards, serially and alone on the host, as its own fragment-retry stage: its second attempts are not in TotalPartitionBusyMilliseconds, the slot busy time behind IdleParallelCapacityMilliseconds or PartitionDurationBalancePercent, so busy time never exceeds the test-execution capacity window it is measured against. The retry time is that stage's own ElapsedMilliseconds (and part of CriticalPathMilliseconds); the per-partition and per-fragment ElapsedMilliseconds of a retried fragment still include both attempts.

In fragment-pool mode partition windows overlap, so a low PartitionDurationBalancePercent does not mean slots idled behind one partition. The receipt's Makespan block (absent in partition-sequential mode) says what bound the run, from the durations the fragments actually took: LowerBoundMilliseconds is host-exclusive work plus the largest of the longest fragment, the busiest exclusive group, and the remaining work spread over the slots; BindingConstraint names which (indivisible-fragment:<selection id>, exclusive-group:<name> or slot-capacity); ExcessOverLowerBoundMilliseconds is the test-execution time above it, the most any other order of the same fragments could have saved; LongestFragment names the longest process. The execution-end event carries the same values. A long run with a small excess and an indivisible-fragment: constraint was set by one fragment (a slow or hung test process), not by scheduling. Replays of green six-slot full-suite runs 36265616595 and 36271755869 reproduce their pool makespans (825.8 s and 667.2 s) and take 1,857 s and 1,042 s when each partition runs its fragments in sequence.

The CLI writes one synchronized stderr line for each preparation, execution, partition, and fragment start/end event while preserving JSON stdout. Fragment receipts retain the exact inferred command. RunnerParallelism.EffectiveParallelism remains null with runner-effective-parallelism-not-exposed unless a future runner contract supplies effective internal concurrency; the outer partition peak is not presented as internal test-runner parallelism. Fragment completion also records root-process total/user/privileged CPU time, average cores consumed (cpuMs / elapsedMs), host-normalized CPU percent, and peak working set. These unprefixed fields always describe only the root dotnet test process, not the test host it launches.

On Windows the root process also joins an accounting-only job object right after it starts (no limits and no kill-on-close, so process lifetime and cleanup are unchanged). The test host and every other descendant inherit the job, and one query when the fragment ends records the whole tree: ProcessTreeCpuMilliseconds (with its user and kernel parts), ProcessTreeAverageCoreUtilization, read, write and total I/O bytes, ProcessTreePeakCommitBytes (the tree's peak private commit at one instant; Windows keeps no tree-wide working-set peak) and ProcessTreeProcessCount. ResourceEvidence is then job-object-tree. Joining and querying take about 60 µs per fragment. Known bound: the root runs before the join, so a descendant it starts in that window never enters the job and nothing detects it; ResourceEvidence stays job-object-tree and the tree totals undercount that descendant. dotnet test starts its test host hundreds of milliseconds after it starts (a prebuilt module runs in the root itself), so only an ETP thread stalled that long before the join, preempted or suspended by a GC, misses it. Process.Start cannot start the root suspended, which is what closing the window would take. Totals are cumulative up to the fragment end, so a process that outlives the fragment, such as a build server it started, is counted only up to then. Other platforms, and a job that cannot be created, joined or queried, keep root-process-only evidence and record ProcessTreeUnavailableReason (process-tree-accounting-unsupported-platform, job-object-create-failed, job-object-assign-failed or job-object-query-failed). The fragment-end event line carries the tree values, and the finalize summary adds tree CPU next to root CPU, the average tree cores, total tree I/O and the largest peak commit over the fragments with tree evidence. Historical v1 receipts lack these required observability fields and must not be treated as v2 evidence.

etp ci plan is the atomic orchestration boundary for the ETP planning stack. It keeps the CLI thin and composes the existing prepare/plan/partition flow in-process, then emits a versioned receipt with the selected or widened outcome and GitHub summary projection. When --build-impact-root identifies a verified compiler snapshot, CI first performs the cheap project-graph pass and then refines it from that portable index. A hit avoids the full Roslyn workspace analysis; missing, stale, or incomplete evidence remains fail-closed at the affected project scope. Incomplete candidate test shards produce a hybrid result: exact methods from trusted shards plus project fallback only for the incomplete candidates. Mixed source and resource/configuration diffs can also produce a hybrid result: declared data ownership retains each consuming test project and its downstream test candidates while independent source changes use compiler evidence. Data files without known consumers remain visible as data-input-no-known-consumer and do not block refinement. A changed .sln/.slnx compiles into no assembly. When the planner attributes it to specific projects (SolutionMemberImpact, or SolutionEntryImpact for entry-level attribution), refinement keeps the whole-project bound for those projects and their reverse-dependent test candidates (an attributed project without candidates adds no bound), and compiler evidence still decides every other candidate. When those bounds already cover every candidate, the refiner skips the declaration diff (build-impact-index-non-source-bounds-every-candidate). Prepare's graph is evaluated without the build's properties, so the refiner also checks each narrowed candidate against the ProjectReferences its carriers were evaluated with for the build: a reference path to a solution-impacted project, or through a project without an evaluated carrier, keeps the conservative selection (build-impact-index-solution-projection-unproven). A solution change the planner could not attribute (incomplete evaluation, members outside the graph, or .sln membership it cannot parse) keeps the conservative selection. Global build inputs retain the conservative selection. Only eng/etp-input-exceptions.json authorizes data triggers, including copied or embedded files. Each rule maps data paths to consuming projects. Declared exceptions take precedence over Markdown/control-plane exclusions; source and build configuration keep their own impact rules.

etp ci plan --workspace . --base-ref origin/main --head-ref HEAD --analysis-depth semantic --test-inventory artifacts/etp-test-inventory.json --build-impact-root artifacts/previous-build-impact --test-duration-baseline .etp/test-durations/etp-test-durations.mpack --planner-p95-ms 15000 --economic-process-startup-ms 1000 --economic-min-saving-ms 10000 --output artifacts/ci-plan.mpack --summary-output artifacts/ci-plan-summary.txt --github-output artifacts/ci-plan-gh-output.txt

After a conservative build has emitted current-head compiler carriers, use etp ci refine-post-build as the atomic adoption boundary. The command reruns the cheap compiler-index plan, proves that its candidate is a semantic subset of the conservative selection, and writes the effective selection and partition manifests. Project-to-class or project-to-method narrowing requires a complete test inventory; a widened candidate, a forced-full candidate, or incomplete evidence exits nonzero and writes a small etp.ci-post-build-refinement.v1 receipt. An equivalent or unusable candidate retains the conservative manifests without failing. A retained forced-full selection fails unless the caller supplies --allow-retained-forced-full for an explicitly authorized full-suite run.

When the candidate selects a test outside the conservative selection, ETP no longer keeps every conservative project whole. Each conservative scope the candidate does not cover is classified:

  • methods and exact/related classes are positive source evidence and are kept;
  • a fallback project or class bound is superseded when the candidate was planned from the same changed inputs, per-project input ownership and planner scope, the candidate's refiner decided every test of that project from complete compiler evidence (CompilerDecidedTestProjects, recorded by the compiler-index refiner for candidate projects without a project fallback), the candidate's own test inventory for that project is complete, and the bound's inventory can be expanded (the proof strict narrowing requires);
  • any other bound is kept, with post-build-source-scope-not-compiler-decided, post-build-source-scope-inventory-incomplete, post-build-source-scope-inventory-empty (a complete inventory without tests would prove it vacuously), post-build-source-scope-evidence-mismatch, post-build-source-scope-forced-full or post-build-source-scope-unsupported.

Strict narrowing and the union share one drop proof: matching planning evidence, a compiler-decided project (for a scope with ambiguous ownership, every project that declares its name), and, for broad scopes, the candidate's own complete, non-empty inventory. A candidate that records no CompilerDecidedTestProjects proves nothing, and covering a broad scope method by method only counts against the non-empty current inventory of a compiler-decided project. Candidates reached through owners pruned by the unindexed-source directory heuristic, consumers of an owning test project, and candidates whose current inventory could not be built are not recorded as decided.

If nothing is kept, the result is post-build-compiler-supersedes-source: the candidate and its own partition are adopted without the safety-union-partitioning replan. Otherwise the kept scopes are reconciled with the candidate as post-build-source-compiler-union. The receipt's optional DiagnosticEvidence lists SupersededSourceScopes and one WideningScopes entry per remaining broad (project or non-exact class) scope: its origin (compiler-candidate or source-plan), reasons, and up to ten causing changed inputs. A candidate project fallback caused by a changed source without compiler evidence (deleted, or without declarations such as global usings or assembly attributes) carries build-impact-index-changed-source-unindexed and names that file exactly; other causes are derived from the project's triggering ownership. The phase log adds supersededSourceScopes, effectiveBroadScopes and broadScopeCauses, the refinement Markdown summary adds a remaining broad scope table, and etp hotspots exposes both lists under ScopeAnalysis. These diagnostics do not enter the refinement decision hash.

etp ci refine-post-build --workspace . `
  --base-ref <exact-base-sha> --head-ref <exact-head-sha> `
  --conservative-selection artifacts/test-selection.mpack `
  --conservative-partition artifacts/test-partitions.mpack `
  --build-impact-root artifacts/current-build-impact `
  --prepare-manifest artifacts/etp-prepare.build.json `
  --selection-output artifacts/test-selection.effective.mpack `
  --partition-output artifacts/test-partitions.effective.mpack `
  --receipt-output artifacts/etp-post-build-refinement.json

For PR delta builds, also pass the verified current-head materialization with --build-impact-overlay. The prepare manifest is validated against the exact base/head pair in both direct-root and overlay modes, but it is forwarded to the planner only with an overlay. This replaces the previous invalid ci plan --build-impact-prepare-manifest direct-root combination.

A receipt is versioned under SchemaVersion: "etp.ci-plan.v1" and records the final Outcome (selected, empty, refresh, forced-full, or conservative-fallback), the selected-test count, the effective partition count, the reason codes, and the deterministic SchemaHash used to bind the receipt to the current request shape. Use --refresh for an explicit cache-refresh receipt; use --force-full for an explicit full-suite receipt.

--test-inventory accepts a canonical MessagePack etp.test-inventory.v1 payload. etp workflow affected-tests plan evaluates this inventory automatically when no external inventory is supplied. Staged CI adapters can use the same evaluator:

etp ci inventory --workspace . --prepare-manifest artifacts/prepare.mpack --output artifacts/test-inventory.mpack

The prepare graph defines standalone test roots; nested fixtures are excluded. Evaluation is sequential, bounded to 90 seconds per project, cancellable, and does not restore or compile. Missing SDK, frameworks or assembly identity fails the command instead of fabricating defaults. Identity inventory has no method coverage claim (IsComplete=false). Transport only the .mpack; consumers no longer need a PowerShell MSBuild evaluator or a duplicate JSON projection. An empty fallback with identical exact base/head commits remains empty; unknown or symbolic diff identities retain the conservative behavior.

The native evaluator launches each test project from its own directory so the closest global.json selects that project's SDK. Every project records SdkVersion, AssemblyName, and all TargetFrameworks; ETP emits one prebuilt module fragment per TFM and retains semantic class/method inventory from the planner. This supports repositories that mix SDKs as well as projects that multitarget without guessing net10.0 or treating a single TFM as a scalar string.

StageTimings on the receipt is the normalized planning timeline. It includes discovery (git-diff), compiler snapshot loading (build-impact-snapshot-load), index refinement, and partitioning when those stages run. CostEvidence.ImpactEvidenceMode distinguishes planner-only, snapshot-refinement, and future snapshot-overlay decisions. The same evidence records selection counts, cache disposition, the timing-baseline source, and separate historical restore, build-only, and test-duration estimates.

When durations export --merge-baseline incorporates a partial test run, ETP retains observations for tests that did not run and applies the configured merge alpha to observations that did. TotalDurationMilliseconds is then recomputed as the sum of positive retained test durations, not blended with the partial run's total. This also repairs a stale aggregate on the next export without changing the baseline schema or creating a second payload. The aggregate represents test-observation time; it is not elapsed suite time and does not include project runner startup overhead.

TRX display names are not unique case identifiers: parameterized tests can print the same truncated arguments for different cases. Export coalesces matching names within each normalized project into a duration bucket, summing their observed durations and retaining failure/flaky evidence. Merge applies alpha once to each old/new bucket, not repeatedly to individual colliding rows. Observation counts track the bucket's run observations; project test counts still come from the raw result rows. Equal names in different projects remain separate. Existing baselines containing duplicate rows are normalized on read-for-merge with the same rule; no case is silently dropped and no second JSON payload is written.

Duration export also reads native partition-execution-receipt.mpack files when given --partition-manifest (JSON or MessagePack). It verifies fragment identities against that manifest and records runner time for a project when its planned fragments completed successfully and either are all whole-project scopes, or are class and class-group fragments that, for each target framework, together ran every class of the project's complete inventory. The inventory comes from --selection-manifest, the run's selection manifest; native finalization uses its effective selection. A class split records one observation: the sum of each process's elapsed time minus its startup allowance, plus one startup allowance per target framework, which is what one process per framework running every class would take. That keeps it comparable with a whole-project observation, which both the whole-project estimate and the class-split decision use. Summed test durations would leave out process and fixture work outside test bodies, double count tests that overlap, and need TRX. Incomplete, failed, all-skipped and method selections, a class that did not run, and a missing or incomplete inventory do not become project observations and leave the previous average untouched. Duplicate identical receipts are not counted twice; conflicting observations and mismatched manifests are rejected. An explicitly supplied partition or selection manifest must exist, deserialize, and use the supported schema even when the result directory has no native receipt. Invalid explicit manifests no longer silently become an unscoped export; omit the option only for intentionally unscoped TRX exports. These observations use RunnerDurationSource: "partition-execution-receipt". They are usable for project estimates even without TRX, but do not synthesize per-test observations, a suite total, or a selected/suite duration ratio.

Every successful fragment that ran a known set of whole classes also records class process times under the project's Classes: class and class-group fragments, and whole-project fragments whose classes the inventory or the run's TRX names. The process's elapsed time beyond its startup allowance is split among its classes by that process's own TRX durations. A class its TRX does not name uses its previous class process time, then its previous summed test durations in the same project, and a class without any evidence takes the mean share. A process that finished within its allowance records zero for its classes, which the partitioner treats as evidence, not as missing data. A class run by several processes, one per target framework, records the mean. Merge applies the alpha to each observed class and keeps the others. The partitioner uses a class process time instead of summed test durations for class selections, for classes coalesced from methods, and for class-group shares. When every class of a process-isolated-safe project has one, their sum replaces the project average for the split decision, for each class group and for the project kept whole, including a project that cannot split. Work beyond the startup allowance, such as a per-process build, is attributed to the classes of the process that paid it. durations summarize omits class process times.

For per-test duration observations, set "reportTrx": true in the partition settings passed to partition, ci plan, or workflow affected-tests plan/run. This is opt-in: the selected test modules must provide Microsoft.Testing.Extensions.TrxReport. ETP preserves the reporting flag on each final executable fragment after batching/coalescing and adds --report-trx at execution time, without changing semantic filters, costs, or partition weights. The report is written in the fragment's isolated results directory and consumed by the same duration exporter. Missing reporter support is a runner error, not a reason to retry the suite with different filters. Existing settings remain off. TRX test durations update per-test history, not the project's measured runner time: a partial report cannot overwrite a complete-project observation or increment its runner observation count. Whole-project runner timing comes from execution evidence. The planner and partitioner enforce that same boundary when reading the baseline: method/class observations are not summed into an alleged complete-project duration. TestCount is an observed count, not a certificate of inventory coverage. Without a project runner observation, whole-project cost remains unknown (or uses an explicit configured default), while failure, flakiness, and exclusivity constraints remain available. Existing per-method observations still support economic coalescing when the planner has a complete inventory and costs every included test; an untyped DefaultWeight is not a duration in milliseconds. No baseline format migration or duplicate payload is required. For economic method/class-to-project coalescing, a measured whole-project runner duration takes precedence over summing method durations (which can overlap under runner parallelism). The complete current inventory gate is unchanged. The configured process-startup allowance remains a separate planning reserve added once per emitted fragment; it is not a measurement of extra wall time. When no whole-project observation exists, ETP can still estimate every inventory method from observations or explicit millisecond defaults.

The duration baseline can also ingest build-timings.json files with SchemaVersion: "etp.build-timings.v1". Each entry records Graph, ProjectPath, RestoreDurationMilliseconds, and BuildDurationMilliseconds. ETP distributes one parallel graph observation across its build roots and compares total wall-clock cost: planner startup + restore/build + selected test duration. This prevents a cheap-looking test assembly from being selected without accounting for the compilation graph required to run it. The receipt reports the broad and selected build estimates, expected net saving, and the economic decision separately from correctness-driven fallback reasons.

etp ci build retains its restore/build stage timings and now starts its clock before graph handling. Its receipt reports either graph-materialization or graph-validation, together with the graph source, root-project count, and serialized graph bytes. This keeps ETP graph overhead visible instead of hiding it before the reported build interval.

ETP-driven builds (CI build stages and the test-phase prerequisite bundle) keep MSBuild node reuse and the Razor build server disabled, but compile through a run-private Roslyn compiler server: every command carries /p:UseRazorBuildServer=false /p:SharedCompilationId=etp-<hash>, where the id is derived from the run root and the ETP process so all stages of one run share one warm VBCSCompiler and no other server on the machine is ever reused. ETP does not force UseSharedCompilation as a global property: the SDK already defaults it to true, and keeping it project-scoped lets ETP switch satellite-assembly compilation to in-process csc (the SDK's satellite Csc call forwards UseSharedCompilation without SharedCompilationId, which would otherwise start and leak the machine-wide server). The switch is injected into every project ETP builds through /p:MSBuildUserExtensionsPath=<tmp>/etp-compiler-server/targets/<content-hash>/msbuild (Current/Microsoft.Common.targets/ImportAfter/Eternet.TestPlanner.CompilerServer.targets), so projects that do not reference Eternet.TestPlanner.Build are covered too; the package ships the same targets for builds outside ETP. The location is content-addressed (the first 16 hex digits of the SHA-256 of the targets text) rather than per server id, the file's LastWriteTimeUtc is pinned to a fixed past instant (2000-01-01 UTC), and it is never deleted at shutdown: every imported file joins $(MSBuildAllProjects), a timestamp input of CoreCompile and other incremental targets, so a per-run or freshly rewritten import would force every project to recompile in the test phase even though the build phase had already produced it. ETP also forces /p:ImportUserLocationsByWildcardAfterMicrosoftCommonTargets=true (an executor-owned property, like MSBuildUserExtensionsPath) so a project or Directory.Build.props that disables wildcard user imports cannot silently skip the injection; the redirected extensions path contains nothing else. Set EternetTestPlannerIsolateSatelliteCompilation=false to opt out; a caller /p:UseSharedCompilation=false still disables the server entirely. ETP shuts that server down through its own pipe when the stage finishes (the receipt records a compiler-server-shutdown timing with its exit code) and never invokes the machine-global dotnet build-server shutdown. Workflow callers that chain stages pass one compilerServerId with keepCompilerServer and call CompilerServerScope.ShutdownAsync once at the end of the phase.

PR-scoped green checkpoints and delta authority

etp.build-impact-delta.v2 is a PR-lineage authority, not a global last-green hint. It binds the repository and pull-request number to a certified head, the trusted PR base or merge-base, an optional parent-head/checkpoint pair, terminal plan/build/test receipt hashes, and schema/tool/compiler fingerprints. Delta operations are canonicalized as deterministic upsert, delete, or tombstone records by project build identity and input path. delete removes a contribution introduced in the checkpoint lineage; tombstone explicitly masks a contribution from the complete base snapshot.

The parent chain is explicit: parentCertifiedHeadSha is a Git commit used for ancestry, while parentCheckpointFingerprint is the SHA-256 identity of the previous checkpoint. Receipt evidence contains a contained relative path, length, and SHA-256; validation reopens each file under --artifact-root and checks its real bytes. Build and test receipts use etp.external-execution-receipt.v1: the workflow records the terminal result of its explicit dotnet build or dotnet test command. ETP validates and seals that evidence only; it does not run the command or transport build outputs.

Validate a transported delta before choosing it as the planning base:

etp build-impact-delta validate --manifest .etp/build-impact-delta.json \
  --workspace . --repository Eternet/Eternet.Agents.Control \
  --pull-request-number 376 \
  --base-ref origin/main --head-ref HEAD \
  --base-or-merge-base <trusted-base-sha> \
  --artifact-root .etp/receipts \
  --schema-fingerprint sha256:<schema> \
  --tool-fingerprint sha256:<tool> \
  --compiler-fingerprint sha256:<compiler>

Exit code 0 accepts the checkpoint. Exit code 2 emits a versioned fallback receipt with actionable reason codes and fallbackTargetSha; callers must plan from that trusted base/merge-base instead. A different PR, foreign repository, force-pushed or rebased non-ancestor head, incomplete/non-green receipts, or any schema/tool/compiler mismatch fails closed. Checkpoints are never selected across PR lineages merely because they are the newest available artifact. After the host restores the content-addressed object and its portable base snapshot to local paths, resolve the complete planning state through ETP:

etp build-impact-delta resolution decide \
  --pointer .etp/current.json \
  --manifest .etp/current/checkpoint.mpack \
  --artifact-root .etp/current \
  --base-snapshot-root .etp/base-snapshot \
  --workspace . --repository Eternet/Eternet.Agents.Control \
  --pull-request-number 376 \
  --base-ref origin/main --head-ref HEAD \
  --graph-fingerprint <graph-sha256> \
  --validation-request-output .etp/checkpoint-validation-request.json \
  --output .etp/checkpoint-resolution.json

The versioned receipt decides checkpoint, source-only, or fallback and contains the chosen base, mode, identities, and nested physical validation. ETP validates pointer v2 identity, content address, Git ancestry, graph and runtime fingerprints, snapshot presence, and terminal receipt bytes. Pointer v1 is deliberately rejected at the boundary; migration is an explicit one-time operation, never a runtime fallback. The host may locate and restore objects, but it must not reconstruct this semantic decision.

When transport cannot supply a pointer or restored object, the adapter reports only that bounded platform fact and ETP still owns the fallback base and receipt:

etp build-impact-delta resolution unavailable \
  --workspace . --repository Eternet/Eternet.Agents.Control \
  --pull-request-number 376 \
  --base-ref origin/main --head-ref HEAD \
  --reason checkpoint-not-found \
  --output .etp/checkpoint-resolution.json

Allowed reasons distinguish store, pointer, object, and snapshot-restore availability. ETP resolves both refs and their merge-base; the host never constructs baseRef, fallbackTargetSha, mode, or baseline usability. When the pointer itself is available but its object or restored snapshot is not, pass --pointer .etp/current.json; ETP retains its certified ancestor as source-only when safe and otherwise falls back to the merge-base. An advanced merge-base can still use that source ancestor when ETP proves the original base precedes both the certified head and current merge-base, and the certified head precedes the current head. The compiler snapshot remains unused.

Preproduction v1 pointers can be converted once, after restoring their exact base snapshot and object, without enabling a v1 reader in normal planning:

etp build-impact-delta pointer migrate-v1 \
  --source-pointer .etp/current-v1.json \
  --manifest .etp/current/checkpoint.mpack \
  --artifact-root .etp/current \
  --base-snapshot-root .etp/base-snapshot \
  --workspace . --repository Eternet/Eternet.Agents.Control \
  --pull-request-number 376 --current-head <head-sha> \
  --graph-fingerprint <graph-sha256> \
  --output .etp/current-v2.json

Migration reconstructs the missing v2 snapshot identity from the v1 baseOrMergeBaseSha, then runs the full v2 resolver. It writes a distinct v2 file only for an accepted checkpoint; otherwise it emits a rejection receipt and leaves the source untouched. The host may atomically replace its trusted pointer only after that receipt reports migrated.

Publication is monotonic and idempotent: an identical checkpoint is a no-op, a late run cannot replace a descendant checkpoint, and incomparable force-push lineages can replace one another only after the candidate has independently passed the complete green validation contract. Ask ETP for the publication decision before taking the platform-specific store lock:

etp build-impact-delta publication decide \
  --candidate-manifest .etp/candidate/checkpoint.mpack \
  --candidate-artifact-root .etp/candidate \
  --existing-manifest .etp/current/checkpoint.mpack \
  --existing-artifact-root .etp/current \
  --workspace . --repository Eternet/Eternet.Agents.Control \
  --pull-request-number 376 --current-head <head-sha> \
  --base-or-merge-base <trusted-base-sha> \
  --schema-fingerprint sha256:<schema> \
  --tool-fingerprint sha256:<tool> \
  --compiler-fingerprint sha256:<compiler> \
  --output .etp/publication-decision.json

The command validates both physical receipt sets and returns publish, no-op, or reject without writing the store. A complete seed may safely re-seed a newer or diverged lineage; a delta must bind the exact valid parent. The host adapter owns credentials, paths, locking, atomic object/pointer copy, retention, and branch/event policy. It must not reinterpret ETP's decision.

For a changed production closure, set EternetTestPlannerImpactIndexMode=delta together with the normal prepare manifest path. The ISG then suppresses the complete project carrier and emits only the declared changed source contributions. The MSBuild target writes one etp.build-impact-delta-fragment.v1 per project + target framework + configuration + platform + runtime identifier + SDK version. Deletes require matching base-carrier evidence; a removed compile item becomes an explicit tombstone. A missing base contribution fails the target instead of inventing a hash.

Point EternetTestPlannerImpactDeltaBaseRoot at the prior complete index snapshot. The package resolves the exact etp-layout-v3 carrier for every project inner build, including multi-project, multi-TFM, multi-RID, and multi-SDK graphs. It rejects a carrier whose embedded identity differs even if the file is present at the requested path. Seed the cache once with a complete full etp-layout-v3 snapshot. Version 2 is not used as an implicit fallback, and a PR delta with no compatible v3 project carrier fails with a seed-required error rather than publishing partial authority. The sole exception is a project/build identity first introduced by the PR: it may emit only upserts with null base fingerprints. Deletes or tombstones still require the exact base carrier or a certified parent checkpoint.

Fragments are deliberately not checkpoint authority. After external build and test commands have terminal green receipts, compose them into the certified contract:

Transport many small fragment files as one deterministic, validated envelope:

etp build-impact-delta archive pack \
  --fragment-root .etp/current-fragments \
  --promotable-only \
  --output-envelope .etp/transport-envelope \
  --receipt-output .etp/receipts/archive-pack.json

etp build-impact-delta archive unpack \
  --envelope .etp/transport-envelope \
  --output-root .etp/restored-fragments \
  --receipt-output .etp/receipts/archive-unpack.json

The ETP v1 envelope contains only manifest.json and impact-delta-fragments.zip. Its manifest binds SHA-256, compressed and uncompressed lengths, file count, and fragment count. ZIP entry names, timestamps, metadata, and ordering are normalized so identical inputs produce identical payload bytes. Pack is strict by default and rejects incomplete or fallback fragments. Explicit publication mode (--promotable-only) first validates every discovered fragment's schema, current authority, schema fingerprint, and unique build identity, then excludes only incomplete/fallback identities and archives each complete fragment with files in that identity's directory. The receipt reports excludedFragments. If no complete fragment remains, the command succeeds with a zero-fragment receipt and does not create or replace the output envelope; callers can skip optional publication. Invalid schema, authority, fingerprint, or duplicate identity always fails and is never treated as an excluded fragment. Unpack validates size and compression-ratio bounds, path traversal (including Windows drive/UNC/backslash forms), duplicate and case-colliding names, symlink/reparse metadata, every fragment schema and authority, then manually extracts to staging and atomically replaces the output. Only the current ETP envelope schema is accepted; loose fragment trees and EAC v1 envelopes must be regenerated. An invalid envelope never falls back to another representation. Receipts are typed JSON on stdout and may also be persisted with --receipt-output. The caller-supplied artifact roots and everything beneath them reject symlink/reparse entries; intentional runner mount or junction ancestors outside those roots are allowed.

etp build-impact-delta compose --request .etp/checkpoint-request.json \
  --parent-manifest .etp/previous-build-impact-delta.json \
  --fragment .etp/index/net10/Eternet.TestPlanner.ImpactDelta.fragment.json \
  --fragment .etp/index/net11/Eternet.TestPlanner.ImpactDelta.fragment.json \
  --artifact-root .etp/receipts --workspace . \
  --output .etp/build-impact-delta.json

The request supplies repository, PR and lineage identities, the tool fingerprint, and physical plan/build/test receipt evidence. The compose command hashes and parses those receipts and proves Git ancestry before it writes a checkpoint. Composition rejects incomplete or mixed-schema fragments, derives the compiler-set fingerprint from every project build identity, canonicalizes all operations, and produces etp.build-impact-delta.v2. Validate that result before selecting the checkpoint as a diff base.

--parent-manifest is optional for C1 and required when advancing an existing PR checkpoint. Composition verifies its repository, PR, merge-base, head, checkpoint hash, schema, tool, SDK/TFM, and per-project compiler identity, then folds current operations into one cumulative merge-base-to-head delta. An add followed by a delete cancels out; later updates retain the original base fingerprint. This keeps one bounded checkpoint per PR instead of requiring an SMB chain walk. Pass the same parent to the MSBuild graph with EternetTestPlannerImpactDeltaParentManifestPath and its trusted hash with EternetTestPlannerImpactDeltaParentCheckpointFingerprint; this lets C2 prove a delete of a file first introduced in C1.

Materialize the validated cumulative overlay without copying build outputs:

etp build-impact-delta materialize \
  --manifest .etp/build-impact-delta.json \
  --validation-request .etp/checkpoint-validation-request.json \
  --base-root .etp/full-v3 \
  --prepare-manifest .etp/prepare.json --workspace . \
  --output .etp/build-impact-overlay.mpack

etp ci plan --workspace . --base-ref <merge-base> --head-ref <head> \
  --build-impact-root .etp/full-v3 \
  --build-impact-overlay .etp/build-impact-overlay.mpack \
  --output .etp/ci-plan.json

Materialization validates physical receipt hashes and Git lineage, reopens the full-v3 carriers, verifies every baseContributionFingerprint, and applies the cumulative operations in memory. It writes etp.build-impact-overlay.v2; ci plan verifies that receipt and its exact base root before refinement and marks cost evidence as snapshot-overlay. Missing, deleted, duplicated, or foreign project/build identities fail closed and cannot advance a checkpoint.

Overlay materialization fingerprints (etp.build-impact-overlay.v2 and etp.build-impact-ephemeral-overlay.v3) certify content, not location. The base index root records where the base snapshot lies on the current runner; every consumer still compares it with its own base root, but the fingerprint leaves it out, so moving the run root between runners never re-certifies an overlay. The rest is split into sections: the header (the materialization without its fingerprint, base index root and shards), each semantic shard without its entries, and that shard's declarations and tests in runs of 4096. Each section's canonical JSON (sorted properties, default string escaping, normalized numbers) is digested with SHA-256 on up to two workers, and the fingerprint is SHA-256 over etp.build-impact-overlay-fingerprint.v2 followed by one line per section in document order: its name, a space and its digest. Changing, adding, removing, moving or reordering any entry, shard or header field changes a line or the line sequence, and relabelling a fingerprint under the other schema never verifies.

Readers accept the previous schemas (etp.build-impact-overlay.v1, etp.build-impact-ephemeral-overlay.v2) during the transition and verify them with their original algorithm: SHA-256 of the canonical JSON of the whole materialization, base index root included. ETP never writes them. Overlays are materialized in the run that consumes them and are not stored across runs, so the transition only matters for a run root planned by an older ETP. Planning certifies such an overlay once in the current schema when it rebases it; checkpoint manifests and their fingerprints are unchanged.

Before the current head has terminal build/test receipts, use the separate local-only overlay contract. It never composes or implies green evidence:

etp build-impact-delta materialize-ephemeral \
  --repository Eternet/Eternet.AspNetCore \
  --base-or-merge-base <merge-base> --current-head <head> \
  --base-root .etp/full-v3 --prepare-manifest .etp/prepare-current.json \
  --parent-overlay .etp/certified-parent-overlay.mpack \
  --fragment .etp/current/Product-net10.fragment.json \
  --fragment .etp/current/Product-net11.fragment.json \
  --workspace . --output .etp/current-head-overlay.mpack

etp ci plan --workspace . --base-ref <parent-or-merge-base> --head-ref <head> \
  --build-impact-root .etp/full-v3 \
  --build-impact-overlay .etp/current-head-overlay.mpack \
  --build-impact-prepare-manifest .etp/prepare-current.json \
  --output .etp/ci-plan.json

etp workflow affected-tests plan --workspace . \
  --base-ref <parent-checkpoint-head> --head-ref <head> \
  --build-impact-root .etp/full-v3 \
  --build-impact-overlay .etp/current-head-overlay.mpack \
  --build-impact-prepare-manifest .etp/prepare-current.json \
  --build-impact-parent-checkpoint .etp/parent/checkpoint.mpack

Omit --parent-overlay for the first head after the full-v3 seed. When it is present it must be an already validated etp.build-impact-overlay.v2 (or legacy v1) for the same repository, merge-base, and ancestry. The output schema is etp.build-impact-ephemeral-overlay.v3; it carries its verified source fragments for later publication by the same native affected-tests run. It keeps authority=current-head-local-nonpublishable and nonPublishable=true, so it cannot be supplied directly as publication authority. It binds the exact prepare file, resolved Git base/head, every production build identity present in the trusted full-v3 seed, every changed production input, compiler/build identities, and current source contribution hashes. Repeat --fragment once per affected production SDK/TFM/configuration/platform/RID identity; a test-only change may omit --fragment. Global, non-source, unattributed, partially covered, or graph-identity changes fall back to the full planner path. ci plan reopens the same prepare file and rejects a stale, foreign, incomplete, tampered, or SDK/TFM/configuration/platform/RID-mismatched overlay before refinement. This path materializes semantic evidence only and never copies assemblies. The standalone ci plan form remains read-only. The native workflow form owns the three inputs and, after terminal green build/test evidence, supplies their parent identity and exact current fragments to build-impact-delta publication prepare --affected-tests-run-root. Use --publication-root when the immutable candidate and pointer must be staged outside the tested checkout. ETP requires both outputs to remain below that explicit boundary and rejects overlap with the workspace, native run, tool installation, or object store. An absent, foreign, stale, non-canonical, or differently fingerprinted parent manifest fails closed; an empty current fragment set may advance only the explicitly consumed parent and never creates a seed from nothing.

GitHub Action

Consumers can use the thin adapter in this repository to install a pinned ETP version and run the atomic planning receipt without any repository-specific changed-path or partition policy:

- name: Resolve the CI plan
  uses: Eternet/Eternet.AspNetCore/.github/actions/etp-ci-plan@<immutable-sha>
  with:
    etp-version: 0.1.0
    package-source: ${{ github.workspace }}/artifacts/etp-tool-package
    workspace: ${{ github.workspace }}
    base-ref: ${{ github.event.pull_request.base.sha }}
    head-ref: ${{ github.sha }}
    analysis-depth: semantic
    output-file: ${{ github.workspace }}/.etp/ci-plan.json
    summary-output-file: ${{ github.workspace }}/.etp/ci-plan-summary.txt
    github-output-file: ${{ github.workspace }}/.etp/ci-plan-gh-output.txt

The action pins the exact tool version, validates the CLI identity with etp --version, rejects mutable version refs like @main, and fails when the resulting receipt schema is not etp.ci-plan.v1. A local feed or package directory is allowed as a bounded startup fallback, but it is never used to invent new selection or partition policy.

Install

Install the package from NuGet when it is published:

dotnet tool install --global Eternet.TestPlanner

For local validation from this repository, pack the CLI and install from the local artifact directory:

dotnet pack tools/Eternet.TestPlanner/src/Eternet.TestPlanner.Cli/Eternet.TestPlanner.Cli.csproj -c Release -o artifacts/etp-nupkg
dotnet tool install --global Eternet.TestPlanner --add-source artifacts/etp-nupkg --version 0.1.0 --ignore-failed-sources

During development, run the project directly:

dotnet run --project tools/Eternet.TestPlanner/src/Eternet.TestPlanner.Cli/Eternet.TestPlanner.Cli.csproj -- plan --workspace . --base-ref origin/main --head-ref HEAD --analysis-depth balanced --output artifacts/test-selection.json

Commands

  • etp prepare evaluates the MSBuild project graph once and emits a deterministic etp.prepare.v2 manifest for later builds and semantic shard reuse. It is a planning receipt only; missing or unknown inputs remain conservative. Consumers can pass the absolute manifest path through EternetTestPlannerPrepareManifestPath; the transitive build targets validate the file and expose it as an AdditionalFiles input without running Git or re-evaluating the project graph.
  • etp plan compares a workspace diff and writes an affected-test selection manifest.
  • etp index precomputes semantic analysis cache entries for later plan runs in the same workspace and cache directory.
  • etp explain explains one selection from an existing manifest.
  • etp hotspots aggregates witness paths from an existing manifest by node and edge kind, including the number of distinct selection entries traversing each node. It helps find broad dispatch bridges without rerunning planning. Both explain and hotspots also read the final selection directly from a native replay ZIP (build/effective-selection.mpack). Counts describe recorded witness paths and selection entries, not the number of tests that could safely be omitted; project and class entries can represent many executable tests. If the ZIP includes build/post-build-refinement.json, the report also includes ScopeAnalysis with before/candidate/effective counts, project and method scopes, narrowing status, and the planner's reason codes. This shows when a conservative project scope offsets more precise candidate selections; WideningScopes names the changed inputs behind each remaining broad scope.
  • etp inspect-replay --archive <native-replay.zip> reads the terminal receipt (or the saved workflow state if finalization did not complete), phase timings, reason codes, partition completion, and which selection artifacts exist. It works when hotspots cannot because the effective selection is absent. The reported selection count is a workflow scope count; a build selection does not establish the effective or executed test scope. Missing partition evidence remains unknown for a state-only archive; available partition receipts supply observed outcomes and timings. BuildProcessMilliseconds sums build process durations, while BuildStageMilliseconds reports elapsed wall time for the build stage. A damaged terminal receipt falls back to the saved state, and a receipt from an earlier workflow attempt is marked superseded. Partition receipts are scoped to the state's active test attempt or the terminal receipt's listed test artifacts. EffectiveScopeStatus reports invalid for a damaged or structurally incomplete effective manifest. When the effective selection is available, BaseSourceOwnershipDiagnostics lists each deleted source's BASE compiler or evaluation outcome, mapped owners and former consumers, and elapsed time. An unproved input names the fallback reason.
  • etp partition writes deterministic partitions from a manifest without rerunning git, MSBuild, or Roslyn analysis.
  • etp audit compares a selection manifest against broader evidence such as TRX results and coverage maps.
  • etp evaluate runs planner corpus cases to measure selection behavior across known diffs.
  • etp ci lane-plan applies the repository's lane policy. Declared controlPlanePaths keep that classification when deleted or renamed; ordered exclusions still apply. A runtime/unknown old path remains a global invalidator even if renamed into a control-plane directory. This policy does not implicitly classify every script under eng/ as control-plane.
  • etp ci lane-test --workspace . --lane-plan artifacts/receipts/lane-plan.mpack --lane-manifest eng/lanes/core.json --run-root artifacts/lane-tests/core --report-trx executes that lane's recorded decision after its build/pack step. The run root must be fresh and inside the execution workspace. ETP validates the lane schema and project closure, preserves selected filters, costs and exclusivity, and executes prebuilt outputs without replanning or rebuilding. It writes one partitions.mpack, a terminal partition-execution-receipt.mpack, a small lane-test-receipt.json (status, timing, counts, distinct projects and the detailed receipt path), process logs and summary.md; pass this run root to duration export. No detailed JSON copy is emitted. A failed module or an empty method match returns nonzero. Full-lane commands use explicit dimensionless default weights, not invented millisecond durations. Upgrade native execution, finalization and duration export together: new run roots discover only the canonical binary execution receipts. Old JSON run roots are archival evidence, not inputs to a newly resumed workflow. Start a fresh run when upgrading; adapters must read the small lane summary instead of parsing detailed execution fragments. The receipt's logical v2 schema is unchanged.
  • etp benchmark runs a reproducible cold/index/warm cache benchmark and writes a machine-readable performance manifest.

Example planning flow:

Use --input-policy eng/LaneWorkflowPaths.json on plan, ci plan, ci refine-post-build, or workflow affected-tests plan|run to share the existing etp.ci-lane-policy.v1 control-plane rules. ci lane-plan passes its policy to the underlying planner automatically. This is explicit: no repository file is automatically trusted by name. General planning accepts control-plane patterns only under infrastructure directories (eng/, .github/, scripts/ci/, .etp/) and only script/config extensions; source, project, solution and imported build files keep their normal impact policy. Ordered negative patterns remain effective.

A repository whose tests do not validate Markdown content can set "markdownAffectsTests": false in that policy. This excludes undeclared .md inputs from test ownership, lane routing and change budgets. The original diff and declared-test-neutral-markdown decision remain in the evidence. Source and build-file changes in the same commit still affect tests. Markdown fixtures that must trigger validation belong in eng/etp-input-exceptions.json; those exceptions take precedence over this setting. This is test selection policy, not permission to omit changed content from packages or deployment.

CI and test-execution inputs are classified explicitly:

Class (reason code) Built-in paths Default impact Explicit mapping
github-workflow .github/workflows/** none for product tests; recorded as data-input-no-known-consumer eng/etp-input-exceptions.json rules select the tests that validate workflows
ci-infrastructure .github/actions/**, scripts/ci/**, eng/ci/**, eng/Test-*.ps1, .etp/ci-*.json same as workflows same as workflows
declared-control-plane-input the policy's controlPlanePaths same as workflows; a plan of only these skips the project graph not needed
test-execution-config *.runsettings, xunit.runner.json, testconfig.json, *.testconfig.json, etp-partition-settings.json, .etp/partition*.json, and the policy's testExecutionInputPaths every top-level test project (TopLevelTestFallback) an eng/etp-input-exceptions.json rule narrows it to the declared consumers
global-build-file Directory.Build.props/.targets/.rsp, global.json, NuGet.config every project none

Workflow and CI-infrastructure files cannot change what product code does; they can change how tests run, which the CI validates with its own contract tests or scripts. Test settings and partition settings are read by the test host or the partitioner, so they fail closed. controlPlanePaths cannot make a test-execution input neutral, and testExecutionInputPaths (repository-relative globs, never source, project or MSBuild files) can only widen a CI, data or documentation classification. A path that matches no class, such as .github/dependabot.yml, keeps the repository-file classification, so a plan that changes only such files widens to the full suite.

For a repository that has verified Markdown files are not evaluated build inputs, set "ignoredExtensions": [".md"] in the same policy. The opt-in allows only reviewed document/data suffixes (.md, .resx, .txt, .csv, .tsv); it does not certify that the files are semantically irrelevant to tests. A repository that embeds Markdown as EmbeddedResource cannot ignore those files. The policy removes verified matching paths from test ownership and lane impact, including declared data that is not evaluated as a critical build input. ETP checks the unscoped evaluated graph before ignoring a present path. Deleted, renamed-away, missing, or unverified paths stay in the changed set and conservatively widen impact. The selection manifest records verified ignored paths separately in ignoredInputs; the lane receipt records ignoredFiles alongside the original changedFiles. All other extensions, including source, project, build, script and configuration formats, are rejected. Evaluated Compile, Analyzer, analyzer config files, EmbeddedResource, AdditionalFiles, markup source, project and imported build inputs cannot be ignored even when their extension is configured. Changes with other extensions in the same diff retain their normal impact.

Repositories that accept missing document/data validation can instead set "unevaluatedIgnoredExtensions": [".md", ".resx", ".txt", ".csv", ".tsv"]. Choose only suffixes whose tests should be omitted. This reusable opt-in applies to every matching file, including EmbeddedResource, AdditionalFiles, generator inputs, deletions and both sides of renames, without evaluating MSBuild to prove irrelevance. It accepts the risk of missed resource, generator and test failures; publish or document evaluation workflows can use a stricter policy. Source and build/configuration suffixes remain rejected, and unknown suffixes retain normal impact. Mixed changes select tests from the other changed files. Receipts emit input-policy-unevaluated-ignore-risk-accepted and per-file UnevaluatedInputIgnoreRiskAccepted. Use the same --input-policy on prepare and subsequent planning commands; native CI propagates it. Prepare filters changed-file ownership but keeps exact evaluated compiler inputs and content hashes, so changing embedded content can still invalidate build evidence and require a rebuild.

Selection evidence records the policy schema, path and SHA-256. Cache storage is partitioned by policy content hash. Native workflows copy the policy into their owned input snapshot and verify it before build/finalize. Post-build refinement rejects a missing or changed policy with post-build-input-policy-mismatch, retaining the original selection in the rejected receipt. Pass the same policy to manually staged commands; do not introduce it only at refinement time.

Selection files support .mpack/.msgpack (compressed MessagePack) and .json. plan --output writes the format selected by the extension; explain and audit read that same payload directly, without creating a JSON sidecar. Prefer .mpack for large persisted selections. JSON remains supported for explicit inspection; null or malformed selections fail instead of becoming empty work.

Compiler-index selections also record one deterministic shortest impact witness per exact method selection in SymbolImpactGraph. etp explain renders its labels from the existing canonical payload: changed declaration, referencing members (including cross-carrier dispatch bridges), and selected test. Tests selected because their own source changed record that source instead of inventing a symbol reference. Shared nodes and edges are deduplicated; unselected graph branches are not serialized. The reverse-dependency traversal visits each reachable node/edge once rather than repeatedly scanning every declaration until convergence. This does not change the selection, receiver analysis, fallback guards, partition weights, or publication authority. In particular, a witness crossing System.Object.ToString or another virtual declaration documents conservative possible dispatch, not proof of a specific runtime receiver. Existing v1 selection readers remain compatible; older payloads without recorded paths must be replanned to gain witnesses, not inferred from labels.

Solution layout comparison distinguishes an absent file at either revision from an existing empty or malformed .slnx. An added/deleted valid solution is a known layout change; invalid XML remains incomplete and conservative.

A .slnx edit is diffed entry by entry, and SolutionChanges in the selection manifest (and the plan summary) records each entry's classification and impact:

Edit Impact Reason code
Folder moves, folder renames, element order none solution-layout-no-op
Project entry added that project and its reverse closure solution-entry-added
Project entry removed, project still in the graph that project and its former dependents solution-entry-removed
Project entry removed, nothing in the graph references it none solution-entry-removed
Solution configurations, platforms, properties or root attributes; solution items (File); a project entry's own content (configuration or platform mapping, BuildDependency, Build/Deploy, Type); an added or removed BuildDependency target members from both revisions and their reverse closure solution-change-unclassified, solution-member-impact
Unknown element, text content, duplicate or non-literal member, malformed XML every graph project solution-layout-evaluation-incomplete

Entry-level impact (solution-entry-level-impact) also has to prove that the graph holds every project reference that can reach the impacted projects: the graph is evaluated without the build's global properties, so a reference behind a condition, Choose branch or target, one dropped by a conditional Remove, one a task outputs into ProjectReference, or one declared in an import the evaluation skipped can be missing. Every ProjectReference declared in a graph project's file or in a build file of the import closure must either appear in that project's evaluated references, or point neither into the impacted projects nor to an existing project outside the graph (whose own references were never evaluated). The closure is every workspace .props/.targets, every evaluated import, and every file an Import names, whatever its extension or condition and inside or outside the workspace (package and custom SDK targets included). Only imports rooted at an MSBuild toolset location such as $(MSBuildToolsPath) are not read. Changed build files are also read at the base revision, with their imports followed, so a reference or import the change deleted still counts. The closure is read once per evaluated graph and effective build-property set, so prepare and plan in one workflow process share it. Declared and imported paths must resolve without evaluation: literals, $(MSBuildThisFileDirectory), or properties whose scanned definitions resolve. Properties supplied to the build with /p: use their effective global values, which override scanned fallback definitions. A task Output whose item type a property names must resolve to another item type, a skipped conditional SDK import is unprovable, and MSBuild %XX escapes are decoded before paths are compared. Otherwise every graph project is kept with solution-entry-reference-unproven, because the hidden referrer may be outside the solution. An added entry outside the evaluated graph keeps the solution-member-graph-incomplete fallback. Classic .sln files keep layout-only detection and otherwise their existing conservative impact.

etp plan --workspace . --base-ref origin/main --head-ref HEAD --output artifacts/test-selection.mpack
etp explain --manifest artifacts/test-selection.mpack --selection 'method:My.Tests.SomeTests.Some_case()'
etp hotspots --manifest artifacts/test-selection.mpack --top 20
etp hotspots --manifest artifacts/native-replay.zip --top 20
etp inspect-replay --archive artifacts/native-replay.zip
etp audit --manifest artifacts/test-selection.mpack --broad-results artifacts/broad.trx
etp prepare --workspace . --changes base-head --base-ref origin/main --head-ref HEAD --output artifacts/etp-prepare.json
etp index --workspace . --analysis-depth balanced --cache-directory .etp/cache --output artifacts/etp-index.json
etp plan --workspace . --base-ref origin/main --head-ref HEAD --analysis-depth balanced --cache-directory .etp/cache --output artifacts/test-selection.json
etp explain --manifest artifacts/test-selection.json --selection-id method:My.Tests.SomeTests.Some_case()
etp partition --manifest artifacts/test-selection.json --partition-count 4 --process-startup-ms 1000 --max-command-fragments 120 --max-method-fragments-per-class 3 --method-selection-class-coverage-threshold 0.6 --max-expanded-class-fragments 60 --max-class-fragments-per-project 12 --class-selection-project-coverage-threshold 0.6 --output artifacts/test-partitions.json

To profile a cold reference-index build while replaying an exact base/head pair:

etp plan --workspace . --base-ref <base-sha> --head-ref <head-sha> --analysis-depth semantic --no-cache --reference-index-profile artifacts/reference-index-profile.json --output artifacts/test-selection.json

etp index accepts the same --reference-index-profile option. The optional etp.reference-index-profile.v1 JSON contains per-project timings, total allocation estimates, and the 20 slowest source-tree visits. Status is computed when the index ran, or identifies the cache/budget/analysis reason when it did not. With a partial project cache hit, only recomputed projects appear in Projects; the selection still uses all cached and recomputed projects. Without the option, etp does not collect or write this profile.

For local CI-shaped iteration, use the repository harness instead of pushing each planner experiment to GitHub:

pwsh -NoProfile -File tools/Eternet.TestPlanner/scripts/Test-CiImpactSelectionLocally.ps1 `
  -BaseRef <base-sha> `
  -HeadRef <head-sha> `
  -PlannerMode local-source `
  -PartitionCount 3 `
  -SmbRoot .eac/scratch/etp-ci-simulation-smb

The harness reproduces plan, partition accounting, SMB pointer/metadata preflight, and optional main-only promotion. Use -PlannerMode published-tool -EtpPath <etp.exe> to compare the installed remote planner with the local source planner. It writes a receipt that keeps selection, partition, cache, and optional test evidence separate.

Example benchmark flow:

etp benchmark `
  --workspace D:\src\Chat\Eternet.Agents.Control `
  --base-ref 47fbb560e6206128f1eac7357ae325e5bae68138 `
  --head-ref 45d05abb94d9fb61e7e60df272957972bd09d23e `
  --analysis-depth semantic `
  --cache-directory .tmp\etp-benchmark-cache `
  --output artifacts\etp-benchmark.json

The benchmark uses existing plan and index mediator pipelines. It records cold plan --no-cache, cold index, warm plan after the semantic-index cache hit, and a second warm plan after the symbol-impact-graph cache hit. The manifest includes elapsed time, selected test count, reason-code counts, selected test identities, graph node/edge counts, cache diagnostics, stage timings, and equivalence flags. When --cache-directory is supplied, it must point to an empty directory so the cold-index lane cannot reuse prior state.

balanced is the recommended rollout depth and maps to the candidate-scoped semantic planner path. semantic remains accepted for compatibility with earlier dogfood workflows, fast-project avoids Roslyn, and deep enables the widest semantic closure for shadow validation and corpus evaluation.

Microsoft Testing Platform

Selection and partition output is intended for Microsoft Testing Platform arguments after dotnet test --. Do not pass these fragments through legacy VSTest --filter arguments.

Project, class, and method selections produce MTP-friendly fragments such as:

dotnet test tests/Some.Tests/Some.Tests.csproj -c Release -- --filter-class "*SomeTests*"
dotnet test tests/Some.Tests/Some.Tests.csproj -c Release -- --filter-class "*SomeTests*" --filter-method "*Some_case*"

Partition manifests contain command fragments per selected test group. A GitHub Actions job can run each matrix row by joining the project path, configuration, and ArgumentsAfterDoubleDash emitted for that row.

etp partition normalizes selections hierarchically. A project fragment removes class and method fragments for the same project. A class fragment removes method fragments for the same class. Optional thresholds can widen methods to class or classes to project when the fine-grained set is no longer cheaper than the broader scope.

--process-startup-ms assigns the fixed cost paid by every emitted test command. With complete test inventory, ETP compares selected method/class execution plus every process startup against the broader class/project total and coalesces only when the latter is cheaper. This comparison requires observed durations or an explicit positive DefaultTestDurationMilliseconds in partition settings for every estimated test. Unitless DefaultWeight remains useful for balancing work, but is not evidence that a test takes that many milliseconds. Explicit scope thresholds remain a separate widening policy.

--max-command-fragments defaults to 120; zero disables it. To meet the limit, ETP batches exact class filters, or method filters belonging to the same class, without adding tests. Batches preserve framework, module, execution strategy, exclusive group and build requirements, and bound filter command-line length. If exact batching cannot meet the limit, partitioning fails with an actionable error instead of widening to whole projects. The partition receipt reports source, emitted, and coalesced fragment counts and keeps historical duration, explicit default weights, and process startup cost separate so no weight can disappear during coalescing.

When --expand-project-fragments-to-classes is enabled, ETP can split a whole-project fragment into class processes, but only for projects listed in ProcessIsolatedSafeProjects whose inventory is complete and declares no shared collection. ETP keeps a global budget before expanding project fragments. --max-expanded-class-fragments defaults to 60; if all planned expansions would exceed that budget, project fragments remain project-scoped so CI runs each affected project once instead of starting hundreds of class-scoped dotnet test processes.

With observed durations for the project or all of its classes, the split is driven by makespan. ETP computes a lower bound on the wall time of the whole fragment set: host-exclusive fragments run alone, fragments sharing an exclusive group run one at a time, and the rest share ExecutionSlotCount slots (partition settings; zero means the partition count). A project that fits under that target stays whole with class-sharding-within-target-makespan. A longer project is packed longest-processing-time-first into the fewest processes, up to ExpandedClassProcessCount, whose longest one fits the target. Every process pays --process-startup-ms again. When no count fits, a process is added only while it saves more than its startup (class-sharding-no-makespan-gain otherwise). Class durations only set each class's share of the observed project run, because they can be sums of tests that ran in parallel. Without duration evidence, the configured ExpandedClassProcessCount still applies. A project that needs the host alone (class-sharding-exclusive-host) or belongs to an exclusive group (class-sharding-serialized-by-exclusive-group) is never split, because its shards would still run one at a time. Set ExecutionSlotCount to the executor's --max-parallelism when every partition runs from one fragment pool. The partition summary reports the bound and what sets it, for example model lower bound with current duration weights over 6 slots: host-exclusive 259900 + shared 982200 = 1242100 (bound by exclusive-group:plugin-global-store). The bound belongs to the model: it is computed from the current duration weights, not measured, so it is neither a floor nor a target for real wall time. A run can finish below it when weights overestimate (EAC run 36273306642: bound 188.6 s, observed 181.6 s) or far above it when they underestimate. etp ci plan appends these partition lines to its summary, and workflow affected-tests finalize reports, under ## Partition estimate, the command cost of the partition that ran and its bound over the slots the fragment pool actually used, next to the observed test execution time. It does so only when one successful run executed the whole partition manifest. When any fragment has only unitless default weights, the section says those values are not wall-clock predictions.

The model treats slots as independent: a fragment takes as long on a busy slot as on an idle one. On EAC's CI hosts (20 logical processors, six slots, xUnit --max-threads 1) the data supports that; see partition-replay-validation.md. On a host whose processors are already saturated, lower ExecutionSlotCount (and the executor's --max-parallelism) to the parallelism the host can actually sustain.

Deferred affected-tests publication

workflow affected-tests finalize --defer-publication writes the normal certified receipt, summary, and savings, then writes publication-intent.json (etp.publication-intent.v1). workflow affected-tests run accepts the same flag. Without it, both commands write exactly what they wrote before and no intent. The intent binds the certification receipt hash and the small manifests each item consumes (hashes taken from the receipt's own artifact list, plus any snapshot-context.json); finalize does not read large evidence such as test/** or binlogs. Its runRoot is always .: the intent describes the directory that contains it, so a run root can be transported. A new intent rotates a publication-receipt.json bound to an older intent to publication-receipt.<first 12 hex of the old intent hash>.json.

workflow affected-tests publish --run-root <dir> holds an exclusive publication.lock in the run root for the whole invocation and fails fast when another publisher holds it. It reads the certification receipt once and checks its hash and status against the intent; a mismatch fails every selected item. A run root without an intent is rejected: hosts keep their existing always-run evidence staging for runs that never reached a deferred finalize, and publish never archives an uncertified run. Each outcome is appended to publication-receipt.json (etp.publication-receipt.v1), bound to the intent hash, and written after every item. An item is settled by its latest decisive attempt: a skip caused by a missing host input cannot hide an earlier failure, while a certification skip (certification-not-green, snapshot-not-certified-complete) can. The exit code is 0 only when every item is settled as published, already-present, or skipped-precondition. An unexpected error in one item records failed with its message, and later items still run. A build-outputs attempt that publishes some entries but skips a row on an I/O failure, or fails to replicate to the shared tier, records published-incomplete (not success); the next publish runs the idempotent publisher again for that item.

Items run in this order; --items <id> selects a subset:

Item Preconditions Behavior
green-tree green Needs --green-tree-root and --green-tree-trusted-store-root (missing: failed). Missing or non-etp.green-tree-seal-preparation.v1 seal-preparation.json: skipped-precondition. The prepared record must name the receipt's tested head and tree and, when frozen, the same native receipt hash; then GreenTreeWorkflowPublisher publishes it.
checkpoint green Needs --repository, --pull-request-number, --fallback-target-sha, --graph-fingerprint; --base-snapshot-sha defaults to the certified head. Prepares locally below --checkpoint-staging-root (default <run-root>/publication/checkpoint/), reading the existing pointer and objects from --build-impact-store-root. Writes the full preparation to <staging>/preparation.json and <run-root>/checkpoint-publication.json, and records its hash and an identity hash in the receipt. no-op is already-present with the preparer reason. A rerun resumes without restaging only for the same identity and an unchanged staged preparation.
snapshot green, complete-snapshot Runs only when the receipt certifies a complete snapshot (snapshot-completion-validated, not a checkpoint reseed); otherwise skipped-precondition. Needs --snapshot-store-root, --repository, --snapshot-input-fingerprint, --etp-version. Scope comes from --snapshot-scope (default repository); the latest pointer moves only with --snapshot-update-pointer, which also requires --snapshot-green-branch (the protected branch the CI promotion verifier proves; this run root is the terminal-green run) and otherwise fails with snapshot-pointer-update-requires-green-promotion. Writes <run-root>/snapshot-publication.json in the standalone command's --output shape.
build-outputs green Publishes certified, reusable build outputs when reuse is enabled; otherwise reports skipped-precondition.
evidence-archive none Last. Needs --evidence-archive <file>.

The evidence archive has the entry set of EAC's "Stage canonical replay evidence outside RAM-disk mount" step: top-level affected-tests-*.json, summary.md, snapshot-publication.json, checkpoint-publication.json, inputs/*, plan/*, build/*.json, build/*.mpack, durations/*, build/*/logs/*.binlog, build/*/*/logs/*.binlog, and recursive test/, build/impact-index/, build/snapshot-impact-index/, inputs/build-impact-base/. Hidden and system files are skipped as Get-ChildItem does without -Force; symbolic links are rejected. --checkpoint-resolution <file> adds checkpoint/resolution.json and --green-tree-root <dir> adds green-tree/**. Each file is read once: it streams into the ZIP while its SHA-256 is computed, compared with the intent's bound hash (durations are not bound because a host reseed may rewrite them), and recorded in the receipt entries; the item sha256 digests that entry list. An existing destination is already-present only when its entry names, lengths, and CRC-32 values equal the freshly streamed archive; otherwise the item fails.

EAC adoption notes:

  • Keep the existing standalone publication steps during rollout. After archive and checkpoint parity is shown, report status right after finalize --defer-publication, then run publish.
  • The checkpoint transport keeps the SMB copy and consumes <staging>/preparation.json instead of preparing again. It must compare-and-swap the store pointer against that file's existingPointerSha256, and when baseSnapshotSha equals the tested head it must still publish the PR-scoped snapshot (--scope pull-request-<n>) before exposing the pointer, exactly as Publish-PrBuildImpactCheckpoint.ps1 does.
  • Native replay publishes snapshots without --snapshot-update-pointer; only a green protected-branch lane publication passes it.
  • Keep the existing green-tree finalize step before publish so its seal preparation is available, and finish any run-root mutation other than a duration reseed before finalize --defer-publication.
  • Runs without an intent (for example, a failure before finalize) keep the legacy always-run evidence staging.

GitHub Actions

Use etp plan once, upload or keep the manifest as a workflow artifact, then use etp partition to create the execution matrix. The planner only selects tests; retry, timeout, parallel execution, and full-suite safety nets remain workflow policy.

Use a single configured cache directory for the workflow. .etp/cache is the recommended local directory because it is ignored by normal source control conventions and can also be reused by local agent loops. Shared CI cache state belongs on the authorized SMB share, not in GitHub Actions cache storage. Cache correctness is never required for safety: when an SMB pointer is missing, stale, unavailable, or schema-incompatible, etp plan --cache-directory ... falls back to the cold semantic path and records cache diagnostics in the manifest.

Repeated complete builds of the same commit can produce different receipt bytes because receipts retain execution paths and diff provenance. For scheduled rebuilds, use build-impact-snapshot publication publish --require-complete-workspace --reuse-existing-snapshot to retain the first certified snapshot for the exact repository, commit, ETP version, scope and input fingerprint. ETP verifies the stored archive and content hashes, metadata proof, carrier count and complete coverage against the current workspace before returning already-published with reason existing-object-validated. It never overwrites an existing snapshot. Without this opt-in, different snapshot bytes remain a rejection.

The recommended CI shape is:

  1. Compute the evaluated-input fingerprint locally and read only the small latest.txt pointer and snapshot metadata from SMB.
  2. Copy the SMB payload only when the pointer, schema, repository, tool version, cache directory, and fingerprint all match.
  3. On a successful push to main, stage and promote the local cache to the immutable SMB snapshot. Pull requests and failed runs never promote.
  4. Run etp plan --cache-directory .etp/cache and let SMB misses fall through to cold planning.
  5. Upload the selection, partition, cache diagnostics, and index manifests as artifacts so misses and timings can be reviewed.
- name: Resolve compatible etp cache from SMB
  shell: pwsh
  run: |
    # Read latest.txt and snapshot-metadata.json first.
    # Copy .etp/cache only after all compatibility checks pass.
    # Missing or incompatible state means cold planning.

- name: Warm etp semantic index
  if: github.event_name == 'push' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
  shell: pwsh
  run: |
    etp index `
      --workspace . `
      --analysis-depth balanced `
      --cache-directory .etp/cache `
      --output artifacts/etp-index.json

- name: Plan affected tests
  if: github.event_name == 'pull_request'
  shell: pwsh
  run: |
    etp plan `
      --workspace . `
      --base-ref ${{ github.event.pull_request.base.sha }} `
      --head-ref ${{ github.sha }} `
      --analysis-depth balanced `
      --cache-directory .etp/cache `
      --max-selected-methods-before-fallback 120 `
      --max-selected-classes-before-fallback 40 `
      --max-selected-method-coverage-before-fallback 0.6 `
      --max-selected-class-coverage-before-fallback 0.6 `
      --output artifacts/test-selection.json

- name: Partition affected tests
  shell: pwsh
  run: |
    etp partition `
      --manifest artifacts/test-selection.json `
      --partition-count 4 `
      --process-startup-ms 1000 `
      --max-command-fragments 120 `
      --max-method-fragments-per-class 3 `
      --method-selection-class-coverage-threshold 0.6 `
      --max-class-fragments-per-project 12 `
      --class-selection-project-coverage-threshold 0.6 `
      --output artifacts/test-partitions.json

etp partition --manifest selection.json --partition-count 4 --output partitions.json reads an existing selection manifest only. It does not call git, MSBuild, or Roslyn. The output contains structured arguments intended to be passed after dotnet test --, partition weights, exclusive grouping, a GitHub Actions matrix, and a compact text summary.

For local EAC or Codex wave loops, keep the cache inside the repo workspace and reuse it between waves:

$cache = Join-Path (Get-Location) '.etp/cache'
etp index --workspace . --analysis-depth balanced --cache-directory $cache --output artifacts/etp-index.json
etp plan --workspace . --base-ref origin/main --head-ref HEAD --analysis-depth balanced --cache-directory $cache --output artifacts/test-selection.json

Do not delete .etp/cache between waves unless validating cold behavior. A warm cache should keep repeated balanced or semantic plan calls close to the P5 dogfood warm-plan range of about 5.6-6.0 seconds after the expensive index entry has been written. If the cache is empty, incompatible, or incomplete, the same command remains safe and pays cold planning cost instead.

etp plan also has project-impact guardrails enabled by default so massive diffs can skip Roslyn and widen immediately to candidate test projects. The default gate requires two independent signals before it cuts over, preferring relationship fan-out over raw bulk:

  • more than 150 changed files;
  • more than 60 changed project graph input files;
  • at least 25% of project graph input files changed;
  • more than 20 affected projects;
  • more than 10 candidate test projects;
  • more than 16 affected-project to candidate-test-project graph edges;
  • more than 5 candidate test projects triggered by any one affected project.

Project graph input files come from evaluated MSBuild inputs, imports, and project files, so docs-only churn does not count as runtime impact unless a repo models those files as project inputs. Repositories can tune the gate without changing workflow code by passing overrides such as --max-project-input-files-before-project-fallback, --max-project-input-file-ratio-before-project-fallback, --max-affected-projects-before-project-fallback, --max-candidate-test-projects-before-project-fallback, and --max-project-test-impact-edges-before-project-fallback, --max-project-test-impact-edge-ratio-before-project-fallback, --max-tests-per-affected-project-before-project-fallback, and --project-impact-fallback-signal-threshold. A threshold of 1 makes any one configured signal enough to widen; the default 2 avoids overreacting on small repositories.

Optional --settings partition.json can configure weights by project, class glob, trait, or fallback reason, plus exclusive groups for tests that must stay together. An exclusive group can additionally set RequiresExclusiveHost when its fragments must not overlap any other test fragment on the runner. ETP runs those fragments first and then restores normal bounded parallelism. Because host-exclusive fragments never overlap anything, ETP merges the exact class or method filters of one test module into a single process (coalescing reason exclusive-host-single-process) so expensive fixtures such as containers or an Aspire AppHost start once instead of once per class; set "CoalesceExclusiveHostFragments": false to keep one process per fragment:

{
  "ExclusiveGroups": [
    {
      "Name": "container-runtime",
      "Project": "tests/Sample.Web.Tests/Sample.Web.Tests.csproj",
      "RequiresExclusiveHost": true
    }
  ]
}

Settings can also assign an execution strategy per project. The default strategy is prebuilt-no-build, which expects the workflow to restore previously built outputs and execute the MTP module directly with dotnet <test-module.dll> --minimum-expected-tests 1. This avoids the VSTest compatibility path where zero discovered tests can exit successfully. Use build-before-test for projects that need a local build in the test job before the direct test-module invocation, such as projects with generated runtime metadata or host assets:

{
  "ExecutionStrategies": [
    {
      "Project": "src/Sample.Web.Tests/Sample.Web.Tests.csproj",
      "Strategy": "build-before-test"
    }
  ]
}

Use mtpArguments for stable runner options that must be passed to every fragment. ETP appends them after the selection filters it owns:

{
  "mtpArguments": ["--max-threads", "4"]
}

History And Audit

Optional --history history.trx or --history history.json supplies local historical signals for partition quality. TRX input contributes test method duration and recent failure markers from UnitTestResult rows. Compact JSON uses the etp.history.v1 shape:

{
  "SchemaVersion": "etp.history.v1",
  "Tests": [
    {
      "FullyQualifiedName": "Sample.Tests.SomeTests.Case()",
      "ClassName": "Sample.Tests.SomeTests",
      "ProjectPath": "tests/Sample.Tests/Sample.Tests.csproj",
      "DurationMilliseconds": 2500,
      "RecentFailure": true,
      "Flaky": false,
      "ExclusiveGroup": "database",
      "LastObservedAt": "2026-06-12T10:15:00Z",
      "Source": "nightly"
    }
  ]
}

History never removes a test required by correctness evidence. Method history wins first, then class duration, then project duration, then configured weights and default weight for partitioning. Restore/build history participates in the economic choice between semantic analysis and conservative project execution; project-scoped duration matching requires an explicit ProjectPath and never infers ownership from historical result-directory names. missing or corrupt history keeps the fail-closed selection and falls back to configured partition weights. Set "PrioritizeRecentFailures": true in partition settings to order recent failures first inside the deterministic partition output.

etp audit is the safety loop for rollout. It should be used in shadow mode to compare narrow selections against broader result evidence before a repository starts enforcing narrower gates.

Selection Reasons

Every selection carries reason fields so reviewers can distinguish candidate scope, selection evidence, fallback, widening, and confidence. Important reason families include:

  • ChangedSymbolReferencedByTest: a test method directly references a changed symbol.
  • ChangedMemberReachedThroughCaller: a changed member is reached through a production caller that a test references.
  • ChangedTestMethodSource or ChangedTestClassSource: the test source itself changed.
  • ChangedCliCommandContractUsedByTest: a changed CLI command declaration and a test share a deterministic command contract such as contracts/scaffold.
  • ChangedMediatorStepSelectedPipelineTests: a changed Eternet.Mediator step, request, handler, or response contract maps to direct step or owning pipeline tests.
  • ChangedMediatorPolicySelectedStepTests: a changed step execution policy maps to tests for the managed step or owning pipeline.
  • ChangedMediatorRetryProviderSelectedPipelineTests: a changed retry provider maps to retry behavior tests for the owning step or pipeline.
  • ChangedMediatorValidatorSelectedPipelineTests: a changed validator maps to validation path tests for the owning step or pipeline.
  • ChangedMediatorBranchStepSelectedBranchTests: a changed branch step maps to branch-specific tests only when both graph metadata and test evidence identify the same branch path.
  • ChangedMediatorPureHelperSelectedPipelineTests: a changed synchronous source-owned pure helper maps through caller generated steps to direct step tests and owning pipeline tests.
  • ChangedMediatorInfrastructureAdapterSelectedPipelineTests: a changed narrow source-owned adapter maps to consuming generated steps and owning pipelines.
  • ChangedMediatorWorkflowDependencySelectedPipelineTests: a changed source-owned workflow dependency maps to consuming pipelines when the dependency fan-out stays within the conservative Mediator budget.
  • GeneratePipelineTestBaseAnchoredPipelineSelection: a generated pipeline test base anchors an owning pipeline test class.
  • MediatorImpactGraphFallback: Mediator graph facts identify impact, but no method or class test evidence is safe enough to prove a narrow selection.
  • MediatorImpactGraphBudgetFallback: helper or dependency graph facts identify consuming pipelines, but the dependency is shared across unrelated source-path feature areas or exceeds the same-feature budget, so candidate test projects are selected instead.
  • central-package-consumer: project is selected because its evaluated package references restore a modified central package entry.
  • central-package-no-op: central package file changed in git diff but contains no package entry modifications.
  • central-package-entry-impact: the central package file changed only through PackageVersion/GlobalPackageReference entries, so impact is limited to each changed package's consumers and their project-reference dependents. The plan summary lists every package with its change and consumers under CentralPackageChanges. With a declared build configuration (--msbuild-property, workflow affected-tests) consumers come from the head graph evaluated as a normal build with the build's global properties; otherwise from the planner's graph.
  • central-package-property-value-unproven: an unchanged entry reads a property that is neither a build global property nor a literal defined earlier in the same plain file, so that package keeps its consumers.
  • central-package-build-graph-dependent: a dependent reached only through a project reference the build's properties enable.
  • central-package-transitive-pinning: central transitive pinning may be enabled, so every project in the file's scope consumes each changed package.
  • central-package-change-unproven: the change is not attributable to package entries (malformed XML, imports, properties, project attributes, unknown elements, entries outside top-level item groups, non-literal ids) or the consumer evidence is unproven (build evaluation failed, a package reference or import reads a property only the build stage injects, a package reference in a target); every project in the file's scope is kept.
  • analyzer-or-source-generator-package: modified central package is an analyzer or source generator, affecting C# compilation and generated code for consuming projects.
  • project-impact-budget-fallback-candidate-scope: the diff is broad enough on project graph metrics that semantic analysis would likely cost more than the saved test time, so candidate test projects are selected before Roslyn loads.
  • UnresolvedSemanticMappingFallback: semantic analysis found changed declarations but no narrow test mapping.
  • semantic-fallback-candidate-scope: the final selection widened to candidate test projects because narrower evidence was unsafe or unavailable.

Fallbacks are not failures. They are explicit evidence that the tool chose a safe wider test set instead of silently skipping uncertain tests.

Cache

etp can persist expensive analysis outputs under .etp/cache when a cache directory is configured or cache is required. Cache entries are versioned by schema, tool identity, analysis depth, scope, inputs, and stage. The current cache schema is invalidated when serialized analysis models change. Entries use the current MessagePack schema only. JSON cache entries are not read or written and should be removed from shared cache stores.

Cache is an optimization only. A cache miss should produce the same selection as a fresh run.

etp index is the cache warm command for CI. It computes the workspace semantic index once and writes the semantic-index cache entry. A later etp plan with the same --analysis-depth and --cache-directory can use that entry for diff plus lookup instead of loading Roslyn compilations again. The cache should be kept even when planner or partition thresholds widen execution to project scope; the next run may still benefit from the warmed inventory and reference index.

Current-only persistence migration

ETP does not dual-write or probe obsolete persistence representations. During an upgrade, delete JSON analysis-cache entries and regenerate EAC v1 or loose build-impact archives with the current producer. Duration baselines must contain an explicit ProjectPath; historical directory-name slugs are not treated as project authority. MessagePack remains the single payload for analysis caches and duration baselines, deterministic ZIP remains the transport for fragment trees, and JSON is reserved for small versioned manifests and receipts.

Pipeline Architecture

The CLI uses System.CommandLine only for parsing and console dispatch. Command orchestration is implemented as Eternet.Mediator generated pipelines:

  • PlanTestSelection: discover changed files, build the MSBuild graph, resolve owning projects and candidate test projects, decide semantic scope, load narrowed Roslyn compilations, discover declarations and test inventory, build the symbol and structural impact graph, then write the manifest.
  • ExplainTestSelection: load an existing manifest, find a selected test, and render the recorded reason path without rerunning repository analysis.
  • PartitionTestSelectionManifest: load an existing manifest, load optional partition settings and history, assign deterministic weighted partitions, build MTP command fragments, then write the partition manifest.
  • AuditTestSelection and EvaluateTestPlannerCorpus: provide rollout evidence, regression checks, and corpus-level comparison for precision hardening.

Cheap gates run before expensive work: git diff and MSBuild ownership first, candidate test scope second, and Roslyn plus structural analysis only for the narrowed candidate closure.

Rollout Guidance

Start with etp plan in shadow mode and keep the existing broad suite. Review manifests for selection mode, reason codes, selected scope, and missing pattern families. Use etp audit against broad TRX evidence before making narrow selection required.

Good rollout signs:

  • Most ordinary source diffs select method or class scopes.
  • Project-scope fallbacks are explainable and tied to broad-impact inputs or missing known patterns.
  • MTP fragments run successfully in the target runner.
  • Nightly or release broad suites remain available.
  • New Eternet patterns are added as deterministic, tested extractors before relying on AI or manual judgment.

For now, Eternet.Agents.Control is the primary dogfood repository. Its CLI command tests are the first structural-contract use case: changes to ContractsCommandModule can now select ContractsCliTests methods by the contracts/scaffold contract instead of widening to the whole CLI test project.

For an opt-in, portable observation contract that distinguishes an actionable planner regression from justified cold or conservative work, see dogfooding. ETP emits evidence only; EAC or another orchestrator owns observation ledgers, thresholds, repair checkpoints, and resume.

Fragment environment and partition settings

Every test-running child process, including a prebuilt module from an external module root, the dotnet test fallback and the legacy runner capability probe, starts with its working directory set to the absolute workspace. ETP_WORKSPACE contains that same path and is reserved. ETP does not set product-specific workspace variables. A product can map one through partition settings:

{
  "TestEnvironmentVariables": {
    "EAC_REPOSITORY_ROOT": "{workspace}"
  }
}

Or pass a repeatable --test-environment NAME=VALUE to workflow affected-tests test|run, execute-test-partitions, or ci lane-test. The literal {workspace} token expands to ETP_WORKSPACE. Values apply in this order and the last one wins: the environment already recorded in the command fragment, the resolved settings, then the explicit --test-environment mappings. ETP_WORKSPACE cannot be overridden from any source. Names must match [A-Za-z_][A-Za-z0-9_]* (at most 256 characters), are unique ignoring case, and values are at most 32,767 characters without NUL; violations fail before any work and never echo the value. The execution receipt lists the CLI override names only; the values are never written to receipts, events or summaries, but they are visible in the process arguments of the ETP invocation, so pass secrets through the environment of the test host instead. Settings entries are persisted in command fragments, so keep secrets out of partition settings. Unspecified variables retain inherited values. ArgumentsAfterDoubleDash remains the MTP command contract.

Partition settings resolve in order:

  1. an explicit --settings (etp partition) or --partition-settings (execute-test-partitions, workflow affected-tests plan|test|run, ci plan);
  2. the settings source already recorded with the workflow state or the partition manifest;
  3. ETP_PARTITION_SETTINGS (a relative value resolves against the workspace; a value that points to a missing file only warns and discovery continues);
  4. <workspace>/.etp/partition-settings.json (discovered);
  5. none.

The workspace is --workspace or the current directory; a workspace path recorded inside a transported manifest never decides where settings are read from. An explicit relative --settings resolves against the current directory in etp partition and execute-test-partitions, and against the workspace in the workflow and ci commands, as before discovery existed. The manifest (PartitionSettingsSource) and the workflow state record the original source kind, its workspace-relative path when it is inside the workspace, and the SHA-256 of the exact bytes read. Manifests stay byte-identical when no settings exist: the property is written only when settings were used or explicitly disabled, and the workflow state carries partition-settings-not-found in its reason codes instead.

A discovered file (environment variable or .etp/partition-settings.json) contributes only exclusiveGroups and testEnvironmentVariables; every other key, such as configuration, mtpArguments, weights or coalescing, is ignored so that discovery can never change the selected tests. Pass such a file with --settings/--partition-settings to use all of its keys. A link at the default path that resolves outside the workspace is ignored with the reason partition-settings-reparse-point. Settings files larger than 8 MB, malformed files and a directory at the default path fail with the source and path in the message.

--no-partition-settings disables every source, is recorded as an explicit opt-out (partition-settings-disabled) and wins over --partition-settings with a warning. At execution it also strips the environment variables a partition-time settings file baked into the manifest's fragments (the capability probe and the test process then see neither them nor anything else the settings supplied; --test-environment and ETP_WORKSPACE are unaffected), and the receipt carries no-partition-settings-stripped-baked-environment when it removed any. Only an explicit source given at execution time fails when unreadable. A recorded source is best effort at execution: a missing or edited file produces a warning event and the receipt reason recorded-partition-settings-missing or recorded-partition-settings-changed, and the run continues with the settings already baked into the fragments. The fragment watchdog is not baked into the fragments, so the partition manifest records the source's fragmentWatchdog policy (an empty policy when the file has no such block) in partitionSettingsSource, and the recorded policy still applies when the file is missing or edited. A manifest written before the policy was recorded cannot say what the file configured: the default watchdog applies and the receipt carries recorded-partition-settings-watchdog-unavailable with a warning event. A fragmentWatchdog in the settings an execution resolves applies to etp execute-test-partitions as well as to the workflow test stage. A manifest that records no settings source (written before provenance existed, or partitioned with no settings at all) receives the exclusive groups discovered at execution, but only the fragments a rule matches by selection id, project or class glob run alone; unrelated groups never serialize the run. Existing CI commands that pass explicit settings keep their precedence.

Failed-test details

When a test fragment fails, ETP reads its TRX reports and emits test-failure events with the test name, outcome, assertion message and stack trace. These fields are also retained in the partition receipt. Enable the runner's TRX reporter to obtain these details; ETP does not require optional reporters for otherwise valid test projects. Missing or malformed reports preserve the original failure and emit a failure-evidence diagnostic with stdout/stderr paths. Parsing and emitted details are bounded, with truncation reported explicitly; original reports and logs remain available for full diagnostics.

Project boundaries and partition policy

Compiler impact from a changed declaration is bounded by its evaluated reverse project-reference closure when ownership and evidence are complete. Shared interface dispatch (for example IDisposable.Dispose) cannot make a helper select tests in unrelated projects. Unknown ownership remains conservative. Explicit interface methods reconcile compiler evidence against source-mapped IL using the declaring type and qualified member identity.

ci plan honors the partition policy's MaxClassFragmentsPerProject; it no longer supplies an implicit 150-class threshold that widens to a whole project. Without an explicit widening policy, command limits use exact filter batching and fail clearly if they cannot be satisfied without running additional tests.

Proven local interface receivers

Compiler carriers can include optional receiverDispatches on methods and tests. Each entry names an interface contract, its concrete targets and allSitesComplete: true. This proof is emitted only when every observed call to that contract in the owner has a bounded, known receiver. Local object creation, ordinary assignments, conversions, conditional/coalescing expressions and synchronous/asynchronous using disposal are supported. Assignments are unioned across the whole method, so later assignments can widen earlier calls.

Unknown factories/parameters, closures, reference escapes, unsupported receiver expressions and open virtual implementations preserve conservative dispatch. The carrier retains raw contract references for older readers. A new reader uses validated proof to replace only the matching source-owned dispatch edges, including explicitly mapped state machines, with concrete targets and a separate contract-change dependency. Unrelated IL edges remain intact; missing or ambiguous metadata does not authorize narrowing. Optional proof participates in contribution fingerprints and survives delta/checkpoint transport.

Existing archived carriers without receiver proof remain conservative. Measuring this improvement on a repository therefore requires fresh compiler evidence; replaying old evidence cannot establish its reduction.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

This package has no dependencies.

Version Downloads Last Updated
1.1.125 41 10/4/2026
1.1.124 40 10/4/2026
1.1.123 194 10/4/2026
1.1.122 45 10/4/2026
1.1.121 48 10/3/2026
1.1.120 130 10/2/2026
1.1.119 66 10/2/2026
1.1.118 86 10/1/2026
1.1.117 70 10/1/2026
1.1.116 77 10/1/2026
1.1.115 90 10/1/2026
1.1.114 77 10/1/2026
1.1.113 95 9/29/2026
1.1.112 203 9/28/2026
1.1.111 96 9/28/2026
1.1.110 111 9/27/2026
1.1.109 100 9/27/2026
1.1.108 153 9/26/2026
1.1.107 107 9/26/2026
1.1.106 95 9/26/2026
Loading failed