Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions .github/workflows/idempotency-release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
name: Idempotency release

# Publishes BuildingBlocks.Idempotency when an idempotency-v* tag is pushed.
# Tag format: idempotency-vX.Y.Z must match <Version> in BuildingBlocks.Idempotency.csproj.
# Example: git tag idempotency-v1.0.0 && git push origin idempotency-v1.0.0

on:
release:
types: [published]
push:
tags:
- "idempotency-v*"
workflow_dispatch:
inputs:
publish_nuget:
description: "Push to nuget.org (requires Trusted Publishing + NUGET_USER)"
type: boolean
default: false

jobs:
release:
if: ${{ github.event_name == 'workflow_dispatch' || startsWith(github.ref_name, 'idempotency-v') }}
runs-on: ubuntu-latest
environment: nuget
permissions:
id-token: write
contents: write
env:
NUGET_USER: ${{ secrets.NUGET_USER }}

steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

- name: Resolve version
id: ver
shell: bash
run: |
set -euo pipefail
PROJECT_VERSION=$(sed -n 's:.*<Version>\(.*\)</Version>.*:\1:p' src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj | head -1)
echo "project=$PROJECT_VERSION" >> "$GITHUB_OUTPUT"
REF="${GITHUB_REF_NAME}"
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
TAG="$REF"
STRIPPED="${TAG#idempotency-v}"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "from_tag=$STRIPPED" >> "$GITHUB_OUTPUT"
if [[ "$STRIPPED" != "$PROJECT_VERSION" ]]; then
echo "::error::Tag version ($STRIPPED) does not match project <Version> ($PROJECT_VERSION)"
exit 1
fi
else
echo "tag=" >> "$GITHUB_OUTPUT"
echo "from_tag=" >> "$GITHUB_OUTPUT"
fi

- name: Test library (net8 / net9 / net10)
run: dotnet test tests/BuildingBlocks/Idempotency.Tests/BuildingBlocks.Idempotency.Tests.csproj -c Release --verbosity minimal

- name: Pack
run: dotnet pack src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj -c Release -o artifacts/nuget -p:ContinuousIntegrationBuild=true

- name: Upload workflow artifacts
uses: actions/upload-artifact@v4
with:
name: nuget-packages-idempotency-release
path: |
artifacts/nuget/*.nupkg
artifacts/nuget/*.snupkg
if-no-files-found: error

- name: Attach packages to GitHub Release
if: github.event_name == 'release'
continue-on-error: true
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
tag_name: ${{ github.ref_name }}
files: |
artifacts/nuget/*.nupkg
artifacts/nuget/*.snupkg

- name: Create GitHub Release for tag
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2
with:
tag_name: ${{ github.ref_name }}
name: BuildingBlocks.Idempotency ${{ steps.ver.outputs.project }}
generate_release_notes: true
files: |
artifacts/nuget/*.nupkg
artifacts/nuget/*.snupkg

- name: NuGet login (Trusted Publishing OIDC)
if: ${{ env.NUGET_USER != '' && (github.event_name != 'workflow_dispatch' || inputs.publish_nuget) }}
id: login
uses: NuGet/login@v1
with:
user: ${{ env.NUGET_USER }}

- name: Push to nuget.org
if: ${{ env.NUGET_USER != '' && (github.event_name != 'workflow_dispatch' || inputs.publish_nuget) && steps.login.outcome == 'success' }}
shell: bash
run: |
set -euo pipefail
shopt -s nullglob
for pkg in artifacts/nuget/*.nupkg; do
dotnet nuget push "$pkg" \
--api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate
done
for spkg in artifacts/nuget/*.snupkg; do
dotnet nuget push "$spkg" \
--api-key "${{ steps.login.outputs.NUGET_API_KEY }}" \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate || true
done

- name: Dry-run notice
if: ${{ env.NUGET_USER == '' || (github.event_name == 'workflow_dispatch' && !inputs.publish_nuget) }}
run: |
echo "Skipped nuget.org push (NUGET_USER unset or publish_nuget=false)."
echo "GitHub Release and workflow artifacts are still produced for tag/release triggers."
49 changes: 49 additions & 0 deletions .github/workflows/idempotency.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
name: Idempotency CI

on:
push:
paths:
- "src/BuildingBlocks/Idempotency/**"
- "tests/BuildingBlocks/Idempotency.Tests/**"
- ".github/workflows/idempotency.yml"
pull_request:
paths:
- "src/BuildingBlocks/Idempotency/**"
- "tests/BuildingBlocks/Idempotency.Tests/**"
- ".github/workflows/idempotency.yml"
workflow_dispatch:

concurrency:
group: idempotency-ci-${{ github.ref }}
cancel-in-progress: true

jobs:
test-and-pack:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4

- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: |
8.0.x
9.0.x
10.0.x

- name: Test library (net8 / net9 / net10)
run: dotnet test tests/BuildingBlocks/Idempotency.Tests/BuildingBlocks.Idempotency.Tests.csproj -c Release --verbosity minimal

- name: Pack
run: dotnet pack src/BuildingBlocks/Idempotency/BuildingBlocks.Idempotency.csproj -c Release -o artifacts/nuget -p:ContinuousIntegrationBuild=true

- name: Upload nupkg
uses: actions/upload-artifact@v4
with:
name: nuget-packages-idempotency
path: |
artifacts/nuget/*.nupkg
artifacts/nuget/*.snupkg
if-no-files-found: error
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,8 @@ docs/**
!docs/building-blocks/**
!docs/adr/
!docs/adr/**
!docs/lab/
!docs/lab/**

# Local-only polluted/autogen solutions
src/src.sln
Expand Down
49 changes: 49 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# FeatureFusion — agent notes

Public **.NET lab** plus extracted MIT **BuildingBlocks**. The application is the laboratory. A package is a reusable result that earned a boundary.

Do not treat FeatureFusion as a framework. Install a BuildingBlock only when it solves a problem the host actually has.

## Lab vs package

| Kind | What it is | Agent docs |
|------|------------|------------|
| **Lab** | Runnable API + Aspire AppHost (`src/Lab/`) | This file + [docs/lab](docs/lab/README.md) + root README |
| **Packable BuildingBlock** | NuGet on nuget.org | `src/BuildingBlocks/<Name>/AGENTS.md` + `PACKAGE_README.md` |
| **In-repo sibling** | Pagination IR / Dapper — not packed | `Pagination/AGENTS.md`, `Pagination.Dapper/AGENTS.md` |
| **Lab-only** | EventBus, YARP rate limit, feature flags | Code + [linkedin catalog](docs/linkedin-posts.md) — no package `AGENTS.md` |

**Lab charter (evidence families, extraction rules, roadmap):** [docs/lab/README.md](docs/lab/README.md). Behavioral experiment catalog: [Experiments README](tests/Lab/IntegrationTests/Experiments/README.md) (Exp 1–18 + MAF prototype).

## Packages (read these first)

| Package | When to choose | AGENTS.md |
|---------|----------------|-----------|
| `BuildingBlocks.Mediator` | CQRS Send + ordered pipeline | [Mediator](src/BuildingBlocks/Mediator/AGENTS.md) |
| `BuildingBlocks.Pagination.EntityFrameworkCore` | Keyset (cursor) paging for EF Core | [Pagination.EF](src/BuildingBlocks/Pagination.EntityFrameworkCore/AGENTS.md) |
| `BuildingBlocks.Mcp` | Same logic as HTTP as deny-by-default MCP tools | [Mcp](src/BuildingBlocks/Mcp/AGENTS.md) |
| `BuildingBlocks.Idempotency` | HTTP Idempotency-Key + optional Redis lock | [Idempotency](src/BuildingBlocks/Idempotency/AGENTS.md) |
| `BuildingBlocks.Telemetry` | Config-driven OpenTelemetry (not a SigNoz SDK) | [Telemetry](src/BuildingBlocks/Telemetry/AGENTS.md) |
| `BuildingBlocks.Aspire.Hosting.SigNoz` | Local AppHost SigNoz only (net10) | [SigNoz](src/BuildingBlocks/Aspire.Hosting.SigNoz/AGENTS.md) |

Pagination IR (`BuildingBlocks.Pagination`) is bundled into the EF Core nupkg. Do not pack it. Dapper pagination is a lab project.

## Human docs

- [README](README.md) — lab + package overview
- [docs/lab](docs/lab/README.md) — lab charter, evidence families, extraction rules
- [docs/building-blocks](docs/building-blocks/) — getting started, mediator, pagination, MCP, telemetry
- [docs/linkedin-posts.md](docs/linkedin-posts.md) — post ↔ code map
- [tests/Lab/IntegrationTests/Experiments/README.md](tests/Lab/IntegrationTests/Experiments/README.md) — behavioral integration experiments (Lab convention + catalog Exp 1–18)
- [CONTRIBUTING.md](CONTRIBUTING.md)
- [llms.txt](llms.txt) — URL index for crawlers

## Lab-only (not NuGet)

- RabbitMQ EventBus + outbox/inbox — `src/Lab/EventBus`
- YARP + Memcached rate limiting — `src/Lab/FeatureFusion.ApiGateway`
- Feature-management filters — lab FeatureFusion

## Related

Writing map: [docs/linkedin-posts.md](docs/linkedin-posts.md). Do not put LinkedIn URLs in NuGet XML docs or `PACKAGE_README.md`.
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,28 @@ All notable changes to **BuildingBlocks** packages in this repository are docume
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## BuildingBlocks.Idempotency [1.0.0] - 2026-09-04

### Added

- ASP.NET Core HTTP **Idempotency-Key** filter via shared `IdempotencyGate`: MVC `[Idempotent]` / `IdempotentAttributeFilter` and Minimal API `WithIdempotency` / `IdempotentEndpointFilter`
- Response envelope replay on Completed keys: status code, content-type, body, plus configurable replay header (default `X-Idempotent-Response`)
- Cache **all HTTP 2xx** successes (not only 200)
- Processing state returns ProblemDetails (default 409) while the lease is active; expired Processing is treated as a miss
- Separate `ProcessingTtl` / `EntryTtl` with optional per-endpoint seconds on attribute / `WithIdempotency`
- Optional Redis SET NX lock via fluent `.UseRedisLock()` / `AddRedisIdempotencyLock` when `UseLock` is true (lock covers GetOrCreate)
- Opt-in request fingerprinting (`EnableRequestFingerprint`): `SHA-256(method + "\n" + path + "\n" + body)`; mismatch → ProblemDetails (default 422); default off (Exp 3 compatible)
- `DuplicateCompletedBehavior` (Replay default / Conflict), configurable processing/duplicate/fingerprint status codes, `MaxKeyLength` + control-character validation
- RFC 9457 `ProblemDetails` errors with stable `type` URIs under `https://buildingblocks.dev/errors/idempotency/`
- Optional ActivitySource via `.UseTelemetry()` / `AddIdempotencyTelemetry` (no cache key tag by default; no BuildingBlocks.Telemetry package dependency)
- Fluent DI: `AddBuildingBlocksIdempotency(...).UseRedisLock().UseTelemetry()`
- Multi-target `net8.0` / `net9.0` / `net10.0`; Lab FeatureFusion hosts MVC on `POST /api/v2/Order/order` and Minimal API smoke on `POST /api/v2/idempotency-smoke` (provenance Exp 3 / 4 / 12)

### Notes

- Distinct from MCP write idempotency (`UseMemoryIdempotency` / `IMcpIdempotencyStore` in BuildingBlocks.Mcp)
- Package depends on `Newtonsoft.Json` (MVC ObjectResult capture), `Ulid`, and `StackExchange.Redis` (Redis lock); hosts that use the lock must register `IConnectionMultiplexer`

## BuildingBlocks.Pagination.EntityFrameworkCore [1.0.0] - 2026-08-29

### Added
Expand Down
16 changes: 14 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,15 @@ Formerly published as `Maxofpower/FeatureManagement`; GitHub redirects the old U
- `dotnet test tests/BuildingBlocks/Mediator.Tests`
- `dotnet test tests/BuildingBlocks/Mediator.Analyzers.Tests`
- `dotnet test tests/BuildingBlocks/Mcp.Tests`
- `dotnet test tests/BuildingBlocks/Idempotency.Tests`
- `dotnet test tests/BuildingBlocks/Pagination.Tests`
- `dotnet test tests/BuildingBlocks/Pagination.EntityFrameworkCore.Tests`
- `dotnet test tests/BuildingBlocks/Pagination.Dapper.Tests`
- `dotnet test tests/BuildingBlocks/Telemetry.Tests`
- `dotnet test tests/BuildingBlocks/Aspire.Hosting.SigNoz.Tests`
4. Pack: `dotnet pack src/BuildingBlocks/Mediator -c Release -o artifacts/nuget` (same for Telemetry, Mcp, `Pagination.EntityFrameworkCore`)
4. Pack: `dotnet pack src/BuildingBlocks/Mediator -c Release -o artifacts/nuget` (same for Telemetry, Mcp, Idempotency, `Pagination.EntityFrameworkCore`)

Release tags (must match the project `<Version>`): `mediator-v*`, `mcp-v*`, `idempotency-v*`, `telemetry-v*`, `pagination-v*`, `telemetry-signoz-v*` (see `.github/workflows/*-release.yml`).

## Guidelines

Expand All @@ -38,6 +41,12 @@ Formerly published as `Maxofpower/FeatureManagement`; GitHub redirects the old U
- Public API changes: update `PublicAPI.Shipped.txt` / XML docs.
- Benchmarks: `dotnet run -c Release --project benchmarks/BuildingBlocks/Pagination.EntityFrameworkCore.Benchmarks -- --filter "*" --job Dry` to smoke; drop `--job Dry` for measurement.

### BuildingBlocks.Idempotency

- HTTP `Idempotency-Key` only — do not fold MCP `IMcpIdempotencyStore` into this package.
- Host owns `IDistributedCache` (and `IConnectionMultiplexer` when using the Redis lock).
- Lab provenance: Experiments 3 / 4 / 12 under `tests/Lab/IntegrationTests/Experiments/`.

### BuildingBlocks.Telemetry / SigNoz hosting

- Keep OpenTelemetry packages on stable versions (no prerelease deps).
Expand All @@ -47,11 +56,14 @@ Formerly published as `Maxofpower/FeatureManagement`; GitHub redirects the old U

- Prefer vertical-slice feature folders under `Features/{Name}/`.
- Leave `Microsoft.FeatureManagement` and `FeatureManagement` JSON as the original feature-flag demo — not the product identity.
- Lab charter and evidence families: [docs/lab/README.md](docs/lab/README.md). Do not invent a Scenario/ExperimentEngine NuGet without crossing the extraction rule there.
- Integration experiments: [Experiments README](tests/Lab/IntegrationTests/Experiments/README.md). Prefer observation helpers over production changes.

Contributions are accepted under the project **MIT** license (inbound = outbound).

## Pull requests

- Prefer focused PRs with tests.
- Run the relevant test project(s) green before requesting review.
- When you publish a LinkedIn post tied to this repo, add a row in [docs/linkedin-posts.md](docs/linkedin-posts.md).
- New packable BuildingBlock: add `AGENTS.md` + `PACKAGE_README.md`, a row in [docs/linkedin-posts.md](docs/linkedin-posts.md), and an entry in the root [AGENTS.md](AGENTS.md) / [llms.txt](llms.txt). Do not put LinkedIn URLs in `PACKAGE_README.md` or packed XML docs.
- When I publish a LinkedIn or Medium post tied to this repo, I'll add a row in [docs/linkedin-posts.md](docs/linkedin-posts.md) and a line in the root README LinkedIn catalog.
36 changes: 36 additions & 0 deletions FeatureFusion.sln
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,14 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Pagination.EntityFrameworkC
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Pagination.EntityFrameworkCore.SqlServer.Tests", "tests\BuildingBlocks\Pagination.EntityFrameworkCore.SqlServer.Tests\BuildingBlocks.Pagination.EntityFrameworkCore.SqlServer.Tests.csproj", "{426AF793-4437-4F21-9333-9CD6656CF70B}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Idempotency", "Idempotency", "{3B6DFA28-E423-47BD-1DC7-709D0430AE1C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Idempotency", "src\BuildingBlocks\Idempotency\BuildingBlocks.Idempotency.csproj", "{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Idempotency.Tests", "Idempotency.Tests", "{E409DBCD-B9E3-14BD-B95F-04CE755E3F82}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BuildingBlocks.Idempotency.Tests", "tests\BuildingBlocks\Idempotency.Tests\BuildingBlocks.Idempotency.Tests.csproj", "{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -479,6 +487,30 @@ Global
{426AF793-4437-4F21-9333-9CD6656CF70B}.Release|x64.Build.0 = Release|Any CPU
{426AF793-4437-4F21-9333-9CD6656CF70B}.Release|x86.ActiveCfg = Release|Any CPU
{426AF793-4437-4F21-9333-9CD6656CF70B}.Release|x86.Build.0 = Release|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Debug|x64.ActiveCfg = Debug|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Debug|x64.Build.0 = Debug|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Debug|x86.ActiveCfg = Debug|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Debug|x86.Build.0 = Debug|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Release|Any CPU.Build.0 = Release|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Release|x64.ActiveCfg = Release|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Release|x64.Build.0 = Release|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Release|x86.ActiveCfg = Release|Any CPU
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108}.Release|x86.Build.0 = Release|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Debug|x64.ActiveCfg = Debug|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Debug|x64.Build.0 = Debug|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Debug|x86.ActiveCfg = Debug|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Debug|x86.Build.0 = Debug|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|Any CPU.Build.0 = Release|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x64.ActiveCfg = Release|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x64.Build.0 = Release|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x86.ActiveCfg = Release|Any CPU
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -528,5 +560,9 @@ Global
{76EF4366-66C3-473E-A70A-8F21C3A9BBFD} = {65FCD0DC-6806-16AE-E6D5-351A72620A08}
{8B381FE9-7898-C0DF-E266-E94C56A9360F} = {8A1B2C3D-4E5F-6789-ABCD-333333333333}
{426AF793-4437-4F21-9333-9CD6656CF70B} = {8B381FE9-7898-C0DF-E266-E94C56A9360F}
{3B6DFA28-E423-47BD-1DC7-709D0430AE1C} = {8A1B2C3D-4E5F-6789-ABCD-111111111111}
{C0B899A7-8C72-4D54-8A9A-E5C65A43F108} = {3B6DFA28-E423-47BD-1DC7-709D0430AE1C}
{E409DBCD-B9E3-14BD-B95F-04CE755E3F82} = {8A1B2C3D-4E5F-6789-ABCD-333333333333}
{2DBA7F51-AC3B-4464-8348-0374FD62FDA0} = {E409DBCD-B9E3-14BD-B95F-04CE755E3F82}
EndGlobalSection
EndGlobal
Loading
Loading