diff --git a/CHANGELOG.md b/CHANGELOG.md
index f5e9307..75ae2fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Security
+## [0.10.1] - 2026-07-24
+
+Maintenance release: the deferred "thorough-review" hardening tier plus the
+`Wolfgang.Etl.Abstractions` 0.17.0 bump. **No public API or behaviour change** to
+either shipped package โ the test doubles and contract-test base classes are
+unchanged.
+
+### Changed
+
+- Built against `Wolfgang.Etl.Abstractions` 0.17.0 (was 0.15.0).
+
+### Security
+
+- Release now publishes via **OIDC / NuGet Trusted Publishing** (`NuGet/login`), removing
+ the long-lived `NUGET_API_KEY` from the release path.
+- Added supply-chain / security CI: transitive-dependency **license audit**, **CycloneDX SBOM**,
+ **OSSF Scorecard**, **Semgrep** SAST, GitHub **Actions audit** (actionlint + zizmor, all
+ actions SHA-pinned), and **build-reproducibility** verification with a per-release
+ reproducible-build manifest attached to each GitHub Release.
+- Documented the release path and compromise scope in `SECURITY.md`, and added a
+ consumer-side reproducible-build verification guide (`docs/REPRODUCIBLE-BUILD.md`).
+
## [0.10.0] - 2026-06-29
Adds an opt-in contract-test base for the `ISupportDryRun` interface introduced in
diff --git a/README.md b/README.md
index 99a8e0a..7db0898 100644
--- a/README.md
+++ b/README.md
@@ -9,6 +9,7 @@ An Extractor, Transformer and Loader designed to be used in testing libraries bu
[](LICENSE)
[](https://dotnet.microsoft.com/)
[](https://github.com/Chris-Wolfgang/ETL-Test-Kit)
+[](https://scorecard.dev/viewer/?uri=github.com/Chris-Wolfgang/ETL-Test-Kit)
---
@@ -239,17 +240,15 @@ public sealed class MyLoaderContractTests
---
-## ๐ฏ Target Frameworks
+## ๐ฏ Supported Frameworks
-Both packages multi-target the following frameworks:
+This library targets:
-| Framework | Versions |
-|-----------|----------|
-| .NET Framework | .NET 4.6.2, .NET 4.8.1 |
-| .NET Standard | .NET Standard 2.0 |
-| .NET | .NET 8.0, .NET 10.0 |
+- **.NET Framework:** 4.6.2, 4.8.1
+- **.NET Standard:** 2.0
+- **.NET:** 8.0, 10.0
----
+See the [NuGet package page](https://www.nuget.org/packages/Wolfgang.Etl.TestKit/) for the authoritative per-TFM compatibility matrix.
## ๐ Code Quality & Static Analysis
@@ -357,6 +356,16 @@ docfx build --serve
---
+## ๐ Verify the build
+
+Every release is built deterministically, and each GitHub Release attaches a
+`reproducible-build-manifest.json` with the SHA-256 of every shipped assembly.
+You can independently rebuild from the tag and confirm the hashes match โ see
+[docs/REPRODUCIBLE-BUILD.md](docs/REPRODUCIBLE-BUILD.md) for the step-by-step
+procedure and how to publish a third-party attestation.
+
+---
+
## ๐ค Contributing
Contributions are welcome! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
diff --git a/REPRODUCIBLE-BUILD.md b/REPRODUCIBLE-BUILD.md
new file mode 100644
index 0000000..e2143ad
--- /dev/null
+++ b/REPRODUCIBLE-BUILD.md
@@ -0,0 +1,50 @@
+# Reproducible builds
+
+Both shipped assemblies โ `Wolfgang.Etl.TestKit` and `Wolfgang.Etl.TestKit.Xunit`
+โ are built to be **byte-for-byte reproducible**: the same source commit produces
+the same compiled output regardless of *where* it is built.
+
+## What makes the build reproducible
+
+`Directory.Build.props` sets the compiler inputs that a reproducible build
+requires:
+
+- `true` โ the compiler emits deterministic
+ output (no embedded timestamps, ordered metadata).
+- `true` (in CI) โ
+ normalises embedded source paths to a deterministic `/_/` root via `PathMap`,
+ so the checkout directory does not leak into the assembly.
+- SourceLink โ embeds the commit SHA rather than machine-local paths.
+
+## How it is verified
+
+[`.github/workflows/reproducible-build.yaml`](.github/workflows/reproducible-build.yaml)
+checks the same commit out to two independent directories, builds each with
+`-p:ContinuousIntegrationBuild=true`, and fails if the produced `.dll`s do not
+hash identically (`sha256sum`). This proves **path-independent** reproducibility
+on a single runner โ the property that lets a third party rebuild and match.
+
+## How to verify it yourself
+
+```bash
+git clone https://github.com/Chris-Wolfgang/ETL-Test-Kit a
+git clone https://github.com/Chris-Wolfgang/ETL-Test-Kit b
+for d in a b; do
+ dotnet build "$d/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj" \
+ -c Release -f net10.0 -p:ContinuousIntegrationBuild=true
+done
+sha256sum \
+ a/src/Wolfgang.Etl.TestKit/bin/Release/net10.0/Wolfgang.Etl.TestKit.dll \
+ b/src/Wolfgang.Etl.TestKit/bin/Release/net10.0/Wolfgang.Etl.TestKit.dll
+# The two hashes must be identical.
+```
+
+## Scope / follow-up
+
+The verification above covers path-independent reproducibility on a single OS โ
+the fleet-proven guarantee. **Cross-OS** byte-identity (building on Ubuntu vs
+Windows and matching) is a stronger claim that is not yet asserted here: `.pdb`
+and some embedded metadata can differ across SDK patch levels and operating
+systems even with deterministic inputs. Extending the matrix to cross-OS
+comparison (with any required `.pdb`/metadata normalisation) is tracked as a
+follow-up to #135.
diff --git a/SECURITY.md b/SECURITY.md
index e2bfe41..fd3c73f 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -21,3 +21,15 @@ We will acknowledge your report within 48 hours and provide an estimated timelin
Your help is greatly appreciated!
Responsible disclosure of security vulnerabilities helps protect our entire community.
+
+## Release path & compromise scope
+
+Facts a maintainer would need at 2am if the release identity is compromised. Generic incident-response steps (rotating credentials, revoking OAuth apps, publishing advisories, unlisting NuGet packages) are not duplicated here โ GitHub's and NuGet's own docs update faster than a checked-in runbook.
+
+- **Release path**: OIDC / NuGet Trusted Publishing via `NuGet/login@v1` in `.github/workflows/release.yaml`. The workflow mints an ephemeral push token per run via OIDC โ the release path does not depend on a long-lived API key stored in GitHub secrets or on the NuGet account. During an incident, check the NuGet account for any long-lived API keys anyway (they can be created outside of CI) and delete anything you don't recognize.
+- **Fallback**: none. If Trusted Publishing is compromised, the incident is at the GitHub-account level (the OIDC identity is `Chris-Wolfgang/ETL-Test-Kit`).
+- **Owner**: @Chris-Wolfgang.
+- **Downstream consumers**: known Wolfgang.* dependents (test projects) include ETL-Xml, ETL-FixedWidth, Etl-DbClient, ETL-Json, and ETL-Transformers; unknown external consumers may also exist on nuget.org.
+- **Package coordinates for unlisting**: this repo ships two packages โ
+ - `Wolfgang.Etl.TestKit` โ https://www.nuget.org/packages/Wolfgang.Etl.TestKit/
+ - `Wolfgang.Etl.TestKit.Xunit` โ https://www.nuget.org/packages/Wolfgang.Etl.TestKit.Xunit/
diff --git a/docfx_project/docfx.json b/docfx_project/docfx.json
index 1c85224..86dae76 100644
--- a/docfx_project/docfx.json
+++ b/docfx_project/docfx.json
@@ -1,65 +1,65 @@
-{
- "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
- "metadata": [
- {
- "src": [
- {
- "files": [
- "src/**/*.csproj"
- ],
- "src": "../"
- }
- ],
- "dest": "api",
- "properties": {
- "TargetFramework": "net8.0"
- },
- "disableGitFeatures": false,
- "disableDefaultFilter": false
- }
- ],
- "build": {
- "content": [
- {
- "files": [
- "**/*.{md,yml}"
- ],
- "exclude": [
- "_site/**"
- ]
- }
- ],
- "resource": [
- {
- "files": [
- "logo.svg",
- "apple-touch-icon.png",
- "favicon.svg",
- "favicon.ico",
- "images/**",
- "public/**",
- "versions.json"
- ]
- }
- ],
- "output": "_site",
- "template": [
- "default",
- "modern"
- ],
- "globalMetadata": {
- "_appName": "Wolfgang.Etl.TestKit",
- "_appTitle": "Wolfgang.Etl.TestKit Documentation",
- "_appLogoPath": "logo.svg",
- "_appFaviconPath": "favicon.svg",
- "_enableSearch": true,
- "_appFooter": "Made with DocFX ",
- "_disableSidebar": false,
- "_disableTocFilter": false,
- "_enableDarkMode": true,
- "colorMode": "dark",
- "_baseUrl": "https://Chris-Wolfgang.github.io/ETL-Test-Kit/",
- "pdf": true
- }
- }
-}
+{
+ "$schema": "https://raw.githubusercontent.com/dotnet/docfx/main/schemas/docfx.schema.json",
+ "metadata": [
+ {
+ "src": [
+ {
+ "files": [
+ "src/**/*.csproj"
+ ],
+ "src": "../"
+ }
+ ],
+ "dest": "api",
+ "properties": {
+ "TargetFramework": "net8.0"
+ },
+ "disableGitFeatures": false,
+ "disableDefaultFilter": false
+ }
+ ],
+ "build": {
+ "content": [
+ {
+ "files": [
+ "**/*.{md,yml}"
+ ],
+ "exclude": [
+ "_site/**"
+ ]
+ }
+ ],
+ "resource": [
+ {
+ "files": [
+ "logo.svg",
+ "apple-touch-icon.png",
+ "favicon.svg",
+ "favicon.ico",
+ "images/**",
+ "public/**",
+ "versions.json"
+ ]
+ }
+ ],
+ "output": "_site",
+ "template": [
+ "default",
+ "modern"
+ ],
+ "globalMetadata": {
+ "_appName": "Wolfgang.Etl.TestKit",
+ "_appTitle": "Wolfgang.Etl.TestKit Documentation",
+ "_appLogoPath": "logo.svg",
+ "_appFaviconPath": "favicon.svg",
+ "_enableSearch": true,
+ "_appFooter": "Made with DocFX ",
+ "_disableSidebar": false,
+ "_disableTocFilter": false,
+ "_enableDarkMode": true,
+ "colorMode": "dark",
+ "_baseUrl": "https://Chris-Wolfgang.github.io/ETL-Test-Kit/",
+ "pdf": true
+ }
+ }
+}
diff --git a/docs/REPRODUCIBLE-BUILD.md b/docs/REPRODUCIBLE-BUILD.md
new file mode 100644
index 0000000..34fa3e3
--- /dev/null
+++ b/docs/REPRODUCIBLE-BUILD.md
@@ -0,0 +1,91 @@
+# Verifying the build is reproducible
+
+Every release of `Wolfgang.Etl.TestKit` and `Wolfgang.Etl.TestKit.Xunit` is built
+deterministically: the same source at the same tag produces byte-identical
+assemblies, independent of who builds it or where. This page lets **you** confirm
+that independently, so "our builds are reproducible" is a checkable claim rather
+than a promise.
+
+CI already proves *same-environment* reproducibility on every push
+([`reproducible-build.yaml`](../.github/workflows/reproducible-build.yaml), #135):
+it builds the library twice and asserts the assembly hashes match. This document
+is the *consumer-side* flip โ how a third party reproduces and attests to it.
+
+## What is published
+
+Each GitHub Release attaches a **`reproducible-build-manifest.json`** listing the
+SHA-256 of every shipped assembly, plus the reference environment (OS and .NET SDK
+version) and the exact build command used to produce them. Example:
+
+```json
+{
+ "schema": "wolfgang.reproducible-build-manifest/v1",
+ "version": "v0.11.0",
+ "targetFramework": "net10.0",
+ "buildCommand": "dotnet build -c Release -f net10.0 -p:ContinuousIntegrationBuild=true",
+ "referenceEnvironment": { "os": "Linux", "dotnetSdk": "10.0.110" },
+ "assemblies": [
+ { "assembly": "Wolfgang.Etl.TestKit.dll", "sha256": "โฆ" },
+ { "assembly": "Wolfgang.Etl.TestKit.Xunit.dll", "sha256": "โฆ" }
+ ]
+}
+```
+
+## Reproduce it yourself
+
+1. **Match the reference environment.** Use the same OS family and .NET SDK
+ version named in the release's manifest (`referenceEnvironment`).
+ `ContinuousIntegrationBuild=true` normalises source paths, so the *checkout
+ location* does not matter โ but the compiler version does, so match the SDK.
+
+2. **Clone at the exact tag:**
+
+ ```bash
+ git clone --branch --depth 1 https://github.com/Chris-Wolfgang/ETL-Test-Kit
+ cd ETL-Test-Kit
+ ```
+
+3. **Build each library with the documented command** (the manifest's
+ `buildCommand`):
+
+ ```bash
+ dotnet build src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj \
+ -c Release -f net10.0 -p:ContinuousIntegrationBuild=true
+ dotnet build src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj \
+ -c Release -f net10.0 -p:ContinuousIntegrationBuild=true
+ ```
+
+4. **Hash your output and compare** against the manifest:
+
+ ```bash
+ sha256sum \
+ src/Wolfgang.Etl.TestKit/bin/Release/net10.0/Wolfgang.Etl.TestKit.dll \
+ src/Wolfgang.Etl.TestKit.Xunit/bin/Release/net10.0/Wolfgang.Etl.TestKit.Xunit.dll
+ ```
+
+ Each hash must equal the corresponding `sha256` in
+ `reproducible-build-manifest.json`. The repo's own generator
+ ([`scripts/reproducible-manifest.sh`](../scripts/reproducible-manifest.sh))
+ runs exactly these steps, so you can also regenerate the whole manifest and
+ `diff` it against the published one.
+
+## If a hash does not match
+
+A mismatch means either the environments differ (most commonly a different SDK
+patch version) or the artifact was tampered with. Please
+[open an issue](https://github.com/Chris-Wolfgang/ETL-Test-Kit/issues/new) titled
+"Reproducible-build mismatch for ``" including:
+
+- the release tag,
+- your OS and `dotnet --version`,
+- your computed hashes vs the manifest's,
+- the exact commands you ran.
+
+## Publishing a third-party attestation
+
+Independent verification is most useful when it is *public*. If you reproduced a
+release successfully, you can publish an attestation following the
+[Reproducible Builds project](https://reproducible-builds.org/) conventions (or a
+service such as [vouchsafe.io](https://vouchsafe.io/)): sign a statement naming the
+tag, the manifest hash, and your environment, and link it back on the mismatch/
+verification issue so others can find corroborating rebuilds.
diff --git a/docs/adr/0001-record-architecture-decisions.md b/docs/adr/0001-record-architecture-decisions.md
new file mode 100644
index 0000000..a32da3a
--- /dev/null
+++ b/docs/adr/0001-record-architecture-decisions.md
@@ -0,0 +1,27 @@
+# 1. Record architecture decisions
+
+## Status
+
+Accepted
+
+## Context
+
+Wolfgang.Etl.TestKit is a small but long-lived pair of NuGet packages (the test
+doubles and the xUnit contract-test base classes) maintained across many release
+cycles, often by different contributors and automated agents. Several non-obvious
+design choices โ the pinned `AssemblyVersion`, the split into two packages, the
+injectable progress timer โ are easy to accidentally undo in a later change
+because the *reasoning* lives only in commit messages or a reviewer's memory.
+
+## Decision
+
+We will keep Architecture Decision Records in `docs/adr/`, one Markdown file per
+decision, in the Nygard format (Context / Decision / Consequences). Records are
+immutable once accepted; a changed decision is captured as a new, superseding ADR.
+
+## Consequences
+
+- The rationale behind load-bearing choices is discoverable next to the code.
+- Reviewers can point at an ADR instead of re-litigating a settled decision.
+- There is a small ongoing cost: a genuinely architectural change should come
+ with an ADR, not just code.
diff --git a/docs/adr/0002-pin-assemblyversion-for-binding-stability.md b/docs/adr/0002-pin-assemblyversion-for-binding-stability.md
new file mode 100644
index 0000000..24783c2
--- /dev/null
+++ b/docs/adr/0002-pin-assemblyversion-for-binding-stability.md
@@ -0,0 +1,33 @@
+# 2. Pin AssemblyVersion at 1.0.0.0 for binding stability
+
+## Status
+
+Accepted
+
+## Context
+
+Both packages ship to NuGet and target .NET Framework TFMs (net462, net481) in
+addition to modern .NET. On .NET Framework, the CLR binds by the assembly's
+**strong `AssemblyVersion`**: if `AssemblyVersion` tracks the package version,
+every minor/patch bump changes the bind identity, so a consumer that references
+`1.2.0` but resolves `1.3.0` at runtime needs an assembly binding redirect or
+fails to load. For a *test-support* library pulled transitively into many test
+projects, that friction is disproportionate to the value.
+
+## Decision
+
+We will pin `` at `1.0.0.0` and let `` and
+`` (derived from ``) carry the real release
+version. `AssemblyVersion` is bumped **only** on a deliberate breaking API change
+(a new major), never on a minor/patch release.
+
+## Consequences
+
+- Consumers do not need binding redirects when a minor/patch bump flows in
+ transitively โ the bind identity is stable across the whole `1.x` line.
+- The actual shipped version is still discoverable via file/informational
+ version and the NuGet package version.
+- The pin is load-bearing: a reviewer must not "fix" `AssemblyVersion` to match
+ the package version. A major bump is the only time it moves.
+- Binary compatibility within a bind identity is additionally guarded by
+ PackageValidation (see the ABI-gate decision and `EnablePackageValidation`).
diff --git a/docs/adr/0003-split-doubles-and-contract-tests-into-two-packages.md b/docs/adr/0003-split-doubles-and-contract-tests-into-two-packages.md
new file mode 100644
index 0000000..54edb35
--- /dev/null
+++ b/docs/adr/0003-split-doubles-and-contract-tests-into-two-packages.md
@@ -0,0 +1,39 @@
+# 3. Ship the test doubles and the contract-test base classes as two packages
+
+## Status
+
+Accepted
+
+## Context
+
+The kit provides two distinct kinds of test support:
+
+1. **Test doubles** (`TestExtractor`, `TestLoader`, `TestTransformer`,
+ and their `Faulty*` variants) โ concrete, framework-agnostic implementations
+ of the Abstractions base classes, usable from any test runner (or none).
+2. **Contract-test base classes** (`ExtractorBaseContractTests<โฆ>`, etc.) โ
+ abstract xUnit `[Fact]`/`[Theory]` suites a downstream library subclasses to
+ verify its own extractor/loader/transformer honours the Abstractions contract.
+
+The second kind hard-depends on xUnit; the first does not. Bundling them would
+force every consumer of the doubles to take an xUnit dependency, including
+consumers on MSTest/NUnit or using the doubles outside a test project (e.g. in
+benchmarks or samples).
+
+## Decision
+
+We will ship two packages: **`Wolfgang.Etl.TestKit`** (the doubles, no test-
+framework dependency) and **`Wolfgang.Etl.TestKit.Xunit`** (the contract-test
+base classes, depends on `Wolfgang.Etl.TestKit` + xUnit). The Xunit package
+references the core package by ProjectReference in-repo and by NuGet version
+downstream.
+
+## Consequences
+
+- Consumers who only need the doubles do not pay for xUnit.
+- The xUnit-specific contract suites live behind an explicit, separately-versioned
+ package boundary; a future MSTest/NUnit contract package can be added without
+ touching the core.
+- Both packages must be versioned and released together in lock-step for the
+ Xunit package's dependency on the core to resolve; the release pipeline packs
+ both.
diff --git a/docs/adr/0004-deterministic-progress-timer-via-injection.md b/docs/adr/0004-deterministic-progress-timer-via-injection.md
new file mode 100644
index 0000000..af3bb9b
--- /dev/null
+++ b/docs/adr/0004-deterministic-progress-timer-via-injection.md
@@ -0,0 +1,39 @@
+# 4. Make progress reporting deterministic via an injectable timer
+
+## Status
+
+Accepted
+
+## Context
+
+The Abstractions base classes report progress on a wall-clock interval
+(`ReportingInterval`) driven by an internal timer. A test that wants to assert
+"a progress report was raised" cannot depend on a real timer firing: the test
+would either sleep (slow, flaky) or race the timer against a fast synchronous
+source that completes โ and unsubscribes the timer's `Elapsed` handler in the
+worker's `finally` โ before the timer ever fires.
+
+## Decision
+
+We will expose progress-timer injection as a first-class part of the doubles and
+the contract-test base classes:
+
+- Every double offers a constructor overload taking an `IProgressTimer`, and the
+ kit ships **`ManualProgressTimer`** whose `Start`/`Stop` are no-ops and which
+ only raises `Elapsed` when `Fire()` is called explicitly.
+- The contract-test base classes drive progress assertions by pulling the first
+ item (so the pipeline is mid-flight), calling `Fire()` deterministically, then
+ draining the rest โ never by waiting on wall-clock time.
+- Implementations wiring an injected timer guard against duplicate `Elapsed`
+ subscriptions when `CreateProgressTimer` is overridden.
+
+## Consequences
+
+- Progress-callback tests are fast and deterministic โ no sleeps, no timing races.
+- The injection seam is public API surface (the `IProgressTimer` constructor
+ overloads and `ManualProgressTimer`) and is therefore guarded by the
+ PublicAPI baseline and PackageValidation; it cannot be removed without a
+ breaking-change bump.
+- Test authors must fire the timer while the pipeline is mid-flight; firing it
+ after a synchronous source has drained observes no callback (documented in the
+ `ManualProgressTimer` example).
diff --git a/docs/adr/README.md b/docs/adr/README.md
new file mode 100644
index 0000000..bbbab89
--- /dev/null
+++ b/docs/adr/README.md
@@ -0,0 +1,24 @@
+# Architecture Decision Records
+
+This directory records the significant architecture / design decisions for
+**Wolfgang.Etl.TestKit** and **Wolfgang.Etl.TestKit.Xunit**, using lightweight
+[Architecture Decision Records (ADRs)](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions).
+
+An ADR captures a single decision: the context that forced it, the decision
+itself, and the consequences (good and bad). ADRs are immutable once accepted โ
+when a decision changes, add a **new** ADR that supersedes the old one rather
+than editing history.
+
+- **[index.md](index.md)** โ the list of all ADRs and their status.
+- **[TEMPLATE.md](TEMPLATE.md)** โ the skeleton to copy when adding one.
+
+## Adding an ADR
+
+1. Copy [`TEMPLATE.md`](TEMPLATE.md) to `NNNN-short-title.md`, numbering it with
+ the next free 4-digit sequence.
+2. Fill in Context / Decision / Consequences (Nygard style).
+3. Set the status to `Proposed`, then `Accepted` once agreed (or
+ `Superseded by ADR-NNNN`).
+4. Add a row to [`index.md`](index.md).
+5. Land the ADR alongside the PR that introduces the decision, so it is part of
+ the review.
diff --git a/docs/adr/TEMPLATE.md b/docs/adr/TEMPLATE.md
new file mode 100644
index 0000000..b2adfc4
--- /dev/null
+++ b/docs/adr/TEMPLATE.md
@@ -0,0 +1,22 @@
+# N. Short title of the decision
+
+## Status
+
+Proposed | Accepted | Superseded by [ADR-NNNN](NNNN-....md)
+
+## Context
+
+What is the issue or force that motivates this decision? Describe the facts and
+constraints โ technical, product, or process โ that are driving the choice.
+State the problem, not the solution.
+
+## Decision
+
+The change we are making, in active voice: "We will ...". Be specific enough
+that a future reader can tell whether later code still honours it.
+
+## Consequences
+
+What becomes easier or harder as a result โ the good, the bad, and the neutral.
+Include any follow-up work, risks, or constraints the decision imposes (e.g. a
+test that guards it, or a rule new code must follow).
diff --git a/docs/adr/index.md b/docs/adr/index.md
new file mode 100644
index 0000000..20966da
--- /dev/null
+++ b/docs/adr/index.md
@@ -0,0 +1,8 @@
+# ADR Index
+
+| ADR | Title | Status |
+| --- | --- | --- |
+| [0001](0001-record-architecture-decisions.md) | Record architecture decisions | Accepted |
+| [0002](0002-pin-assemblyversion-for-binding-stability.md) | Pin AssemblyVersion at 1.0.0.0 for binding stability | Accepted |
+| [0003](0003-split-doubles-and-contract-tests-into-two-packages.md) | Ship the test doubles and the contract-test base classes as two packages | Accepted |
+| [0004](0004-deterministic-progress-timer-via-injection.md) | Make progress reporting deterministic via an injectable timer | Accepted |
diff --git a/docs/migrations/README.md b/docs/migrations/README.md
new file mode 100644
index 0000000..af62752
--- /dev/null
+++ b/docs/migrations/README.md
@@ -0,0 +1,12 @@
+# Migration guides
+
+Per-major-version upgrade guides live here, named `vX-to-vY.md`, created during
+release prep for any major bump (0.x โ 1.0 with breaking changes, or 1.0 โ 2.0)
+and linked from the corresponding GitHub Release notes. A guide covers both
+shipped packages (`Wolfgang.Etl.TestKit` and `Wolfgang.Etl.TestKit.Xunit`), which
+are released in lock-step.
+
+No major version has shipped yet โ ETL-Test-Kit is in its `0.x` line โ so only
+the [template](TEMPLATE-major-version-migration.md) exists. The convention is
+established now so the first major release can use it without inventing structure
+under time pressure.
diff --git a/docs/migrations/TEMPLATE-major-version-migration.md b/docs/migrations/TEMPLATE-major-version-migration.md
new file mode 100644
index 0000000..0c8db96
--- /dev/null
+++ b/docs/migrations/TEMPLATE-major-version-migration.md
@@ -0,0 +1,47 @@
+# Migrating from vX to vY
+
+> Copy this template to `vX-to-vY.md` during release prep for a major version
+> bump, fill in each section, and link it from the GitHub Release notes. Delete
+> any section that does not apply (but prefer "None" over deletion so readers know
+> it was considered).
+
+## Summary
+
+One paragraph: who is affected, roughly how much work the upgrade is, and whether
+a compatibility shim exists.
+
+## Breaking-change inventory
+
+| API | Change | Replacement |
+| --- | --- | --- |
+| `OldType.OldMember` | removed / renamed / behaviour change | `NewType.NewMember` |
+
+## Before / after
+
+```csharp
+// Before (vX)
+```
+
+```csharp
+// After (vY)
+```
+
+Repeat per breaking change that needs a code edit.
+
+## Behavioural changes (no signature change)
+
+Changes that compile unchanged but behave differently at runtime (default-value
+changes, nullability flips, parsing-tolerance changes). These are the dangerous
+ones โ call each out explicitly.
+
+## Deprecation timeline
+
+- **vX**: member marked `[Obsolete]` (warning).
+- **vY**: member removed.
+
+State when deprecated members were first warned about and when they were removed.
+
+## Verifying the upgrade
+
+How a consumer confirms the migration succeeded (build clean, tests green, ABI
+check via the release `api-compat` gate).
diff --git a/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs b/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs
index 1fa9c1c..cd84696 100644
--- a/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs
+++ b/src/Wolfgang.Etl.TestKit.Xunit/ManualProgressTimer.cs
@@ -21,9 +21,8 @@ namespace Wolfgang.Etl.TestKit.Xunit;
///
///
///
-/// // In your concrete contract test class:
-/// protected override MyExtractor CreateSutWithTimer(IProgressTimer timer) =>
-/// new MyExtractor(sourceData, timer);
+/// // In your concrete contract test class, override CreateSutWithTimer to return
+/// // your SUT wired to the supplied timer (see ExtractorBaseContractTests).
///
/// // The base class uses it like this in the progress callback test:
/// var timer = new ManualProgressTimer();
diff --git a/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj b/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj
index 28b7142..11d6733 100644
--- a/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj
+++ b/src/Wolfgang.Etl.TestKit.Xunit/Wolfgang.Etl.TestKit.Xunit.csproj
@@ -4,7 +4,7 @@
net462;net481;netstandard2.0;net8.0;net10.0latestdisable
- 0.10.0
+ 0.10.1
+ true
+ 0.10.0Wolfgang.Etl.TestKit.XunitAbstract xUnit contract test base classes for verifying custom ETL extractors, transformers, and loaders built on Wolfgang.Etl.Abstractions.https://github.com/Chris-Wolfgang/ETL-Test-Kit
diff --git a/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj b/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj
index a35157c..e6d516e 100644
--- a/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj
+++ b/src/Wolfgang.Etl.TestKit/Wolfgang.Etl.TestKit.csproj
@@ -4,7 +4,7 @@
net462;net481;netstandard2.0;net8.0;net10.0latestdisable
- 0.10.0
+ 0.10.1
+ true
+ 0.10.0Wolfgang.Etl.TestKitAn implementation of Extractor, Transformer and Loader for use in ETL examples and benchmarks. Built on Wolfgang.Etl.Abstractions.https://github.com/Chris-Wolfgang/ETL-Test-Kit
diff --git a/tests/Wolfgang.Etl.TestKit.Tests.Unit/AllocationRegressionTests.cs b/tests/Wolfgang.Etl.TestKit.Tests.Unit/AllocationRegressionTests.cs
new file mode 100644
index 0000000..310ce5f
--- /dev/null
+++ b/tests/Wolfgang.Etl.TestKit.Tests.Unit/AllocationRegressionTests.cs
@@ -0,0 +1,143 @@
+#if NET6_0_OR_GREATER
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Wolfgang.Etl.TestKit;
+using Xunit;
+
+namespace Wolfgang.Etl.TestKit.Tests.Unit;
+
+///
+/// Guards the doubles' per-item hot path against allocation regressions (#136).
+///
+///
+/// The doubles exist to feed benchmarks of the real ETL code, so any per-item
+/// allocation they introduce shows up as noise in those measurements. The
+/// enumeration hot paths below are intended to be zero-allocation per item
+/// (a constant, one-time setup cost independent of the item count):
+///
+/// via ExtractAsync()
+/// via LoadAsync(...) with collectItems: false
+/// via TransformAsync(...)
+///
+/// Each test measures allocation over N and 10ยทN items and asserts the marginal
+/// per-item allocation stays below a small threshold โ a real regression (a
+/// per-item List.Add, boxing an int at ~24 B/item, an LINQ closure)
+/// pushes the marginal cost far above it, while one-time setup and background
+/// noise cancel out of the delta. Measured baseline: ~0 B/item on all three.
+/// Guarded to net6.0+ where exists.
+///
+public sealed class AllocationRegressionTests
+{
+ // Real per-item regressions allocate >= 24 bytes/item (boxing) or an amortized
+ // slab (List growth). 4 bytes/item is comfortably above measurement noise yet
+ // an order of magnitude below any genuine per-item allocation.
+ // Real per-item regressions allocate >= 24 bytes/item (boxing) or an amortized
+ // slab (List growth). 8 bytes/item sits an order of magnitude below that while
+ // tolerating the background-allocation noise the process-wide counter picks up
+ // on a shared CI runner (this budget was 4.0 and proved flaky on linux-x64).
+ private const double MaxBytesPerItem = 8.0;
+
+ // A large denominator amortizes any fixed background-allocation spike that
+ // lands inside a measurement window: 450k marginal items means even a 1 MB
+ // stray allocation only reads as ~2.3 B/item.
+ private const int BaseCount = 50_000;
+
+ private const int Attempts = 5;
+
+ [Fact]
+ public async Task ExtractAsync_does_not_allocate_per_item()
+ {
+ await AssertZeroAllocPerItem(async count =>
+ {
+ var extractor = new TestExtractor(new int[count]);
+ var seen = 0;
+ await foreach (var _ in extractor.ExtractAsync())
+ {
+ seen++;
+ }
+
+ return seen;
+ });
+ }
+
+ [Fact]
+ public async Task LoadAsync_does_not_allocate_per_item()
+ {
+ await AssertZeroAllocPerItem(async count =>
+ {
+ var loader = new TestLoader(collectItems: false);
+ await loader.LoadAsync(Range(count));
+ return count;
+ });
+ }
+
+ [Fact]
+ public async Task TransformAsync_does_not_allocate_per_item()
+ {
+ await AssertZeroAllocPerItem(async count =>
+ {
+ var transformer = new TestTransformer();
+ var seen = 0;
+ await foreach (var _ in transformer.TransformAsync(Range(count)))
+ {
+ seen++;
+ }
+
+ return seen;
+ });
+ }
+
+ // Measures the marginal per-item allocation as (alloc(10N) - alloc(N)) / (9N),
+ // which cancels the one-time setup cost, and takes the minimum across a few
+ // runs to shed transient background-allocation noise. Fails if the marginal
+ // per-item allocation exceeds the threshold.
+ private static async Task AssertZeroAllocPerItem(Func> run)
+ {
+ // Warm up so JIT / first-run allocations do not land in the measurement.
+ await run(BaseCount);
+ await run(BaseCount * 10);
+
+ var best = double.MaxValue;
+
+ for (var attempt = 0; attempt < Attempts; attempt++)
+ {
+ var small = await Measure(run, BaseCount);
+ var large = await Measure(run, BaseCount * 10);
+
+ var perItem = (double)(large - small) / (BaseCount * 10 - BaseCount);
+ best = Math.Min(best, perItem);
+ }
+
+ Assert.True
+ (
+ best < MaxBytesPerItem,
+ $"Per-item allocation {best:F3} B exceeds the {MaxBytesPerItem} B/item budget โ the hot path regressed to allocating per item."
+ );
+ }
+
+ private static async Task Measure(Func> run, int count)
+ {
+ // Settle pending finalizers/collections first: the counter is process-wide,
+ // so anything the runtime is still cleaning up would otherwise land inside
+ // the measurement window and inflate the reading.
+ GC.Collect();
+ GC.WaitForPendingFinalizers();
+ GC.Collect();
+
+ var before = GC.GetTotalAllocatedBytes(precise: true);
+ await run(count);
+ return GC.GetTotalAllocatedBytes(precise: true) - before;
+ }
+
+ private static async IAsyncEnumerable Range(int count)
+ {
+ for (var i = 0; i < count; i++)
+ {
+ yield return i;
+ }
+
+ await Task.CompletedTask;
+ }
+}
+#endif
diff --git a/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureInvarianceTests.cs b/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureInvarianceTests.cs
new file mode 100644
index 0000000..02b7d04
--- /dev/null
+++ b/tests/Wolfgang.Etl.TestKit.Tests.Unit/Globalization/CultureInvarianceTests.cs
@@ -0,0 +1,98 @@
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Wolfgang.Etl.TestKit;
+using Xunit;
+
+namespace Wolfgang.Etl.TestKit.Tests.Unit.Globalization;
+
+///
+/// Verifies the doubles are culture-invariant (#134): the same input must produce
+/// the same output regardless of the ambient
+/// / . The suite runs the extract โ
+/// transform โ load pipeline under a matrix of hostile cultures โ en-US,
+/// tr-TR (dotted-I), de-DE (decimal comma), zh-CN, ar-SA
+/// (Hindi-Arabic digits, RTL), and ja-JP (full-width digits) โ exercising the
+/// number-separator, digit-shape, and case-folding bug classes.
+///
+///
+/// Culture-sensitivity allowlist. The doubles expose no public
+/// method that is intentionally culture-sensitive: they move the caller's items
+/// through the pipeline by reference/value without formatting or parsing them, and
+/// the windowing / counting logic is pure integer arithmetic. The allowlist is
+/// therefore empty and these tests assert the whole surface is invariant by
+/// contract โ if a future edit introduces culture-sensitive formatting (e.g. a
+/// double that yields value.ToString() under the ambient culture), the
+/// non-en-US rows fail.
+///
+public class CultureInvarianceTests
+{
+ ///
+ /// Hostile cultures the pipeline runs under. en-US is the baseline;
+ /// the rest each stress a distinct globalization bug class.
+ ///
+ public static readonly IEnumerable