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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,21 @@ This project follows [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Added
- `SettingWrite.GroupType`: the settings class a write was made to. An observer that needs the group's attributes no longer has to scan assemblies for a class whose resolved name matches `GroupName`. It is an init property, so code that constructs a `SettingWrite` keeps compiling.
- The library's first metrics, on a `DotNetBoost.Settings` meter: `dotnetboost.settings.write_observer.failures` and `dotnetboost.settings.change_handler.failures`, each tagged with the implementation's class name, the group and `error.type`. Observer and handler exceptions are still logged and swallowed; until now the log line was the only trace of them. The meter is created through the container's `IMeterFactory` when there is one.
- `ISettingWriteParticipant` and `.UseWriteParticipant<T>()`: a hook that runs inside every write, before the store saves, for work that must commit with the write or not at all — an audit entry. A participant enlists entities through `EfCoreSettingWriteScope.Add`, and the Entity Framework Core store's own save commits them with the rows. An exception aborts the write: the store is not called, the cache keeps its entry, and what was enlisted is discarded. Participants and observers receive the same `SettingWrite` instance, so they share a correlation id. See [Write notifications](docs/write-notifications.md#isettingwriteparticipant).
- `IEnlistingSettingStore`, the store capability participants require, and a `SettingBuilderGuard.EnsureProviderNotConfigured` overload through which a provider declares it. Only Entity Framework Core does. `Build()` refuses a participant for Dapper or MongoDB, and a write refuses one at run time for a store that cannot enlist, rather than run it with no atomicity.

### Changed
- **A clear now reports what it deleted.** A `Cleared` `SettingWrite` used to carry an empty `Changes`; it now lists one `SettingChange` per deleted row, with the stored value as `OldValue` and a null `NewValue`, redacted for `[Sensitive]` properties and for any encrypted row. The manager reads the group before deleting to do it, but only when a participant or observer is registered. An observer that relied on `Changes` being empty for a clear should check `Kind` instead.
- `EfCoreSettingStore.DeleteGroupAsync`, when a participant has enlisted something, runs the delete and a `SaveChangesAsync` in one transaction: the caller's if there is one, otherwise its own inside the context's execution strategy, so it works under `EnableRetryOnFailure`. With nothing enlisted it is the single `ExecuteDeleteAsync` it was.
- Aspire moved from 13.5.2 to 13.6.4, and Microsoft.Extensions.* and EF Core packages from 10.0.11 to 10.0.12 — the floor Aspire 13.6 requires.
- Microsoft.Data.SqlClient (integration tests and sample only) moved from 6.1.6 to 6.1.8, which takes System.Security.Cryptography.Pkcs 9.0.21 and clears CVE-2026-70162 and CVE-2026-84008.

### Fixed
- A failed Entity Framework Core write no longer leaves its `Setting` entities tracked. After a `SettingConcurrencyException` the rows the store had modified or added stayed in the change tracker of the scoped, usually shared, context, so the application's next unrelated `SaveChangesAsync` wrote the losing value over the winner's. They are now detached when the save fails.

## [1.0.0-preview.2] — 2026-10-10

The second prerelease. **One breaking change from `1.0.0-preview.1`:** the built-in audit trail
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,8 @@ builder.Services.AddSettings()
.UseAesEncryption(key) // encrypt [Sensitive] properties
.UseFluentValidation(typeof(Program).Assembly) // reject invalid values
.OnChanged<MailSettings, MailSettingsChanged>() // react when a value changes
.UseWriteObserver<MyWriteObserver>() // record or forward every write
.UseWriteParticipant<MyAuditParticipant>() // commit an audit entry with every write
.UseWriteObserver<MyWriteObserver>() // forward every committed write
.Build();
```

Expand All @@ -198,7 +199,7 @@ builder.Services.AddSettings()
| Run on several servers and keep their caches in step | [Caching](https://github.com/dotnetboost/settings/blob/main/docs/caching.md#running-on-more-than-one-instance) |
| Run code when a setting changes | [Change notifications](https://github.com/dotnetboost/settings/blob/main/docs/change-notifications.md) |
| Stop two people overwriting each other's edits | [Reading & writing](https://github.com/dotnetboost/settings/blob/main/docs/reading-and-writing.md#concurrent-writes) |
| Record or forward every write (and who made it) | [Write notifications](https://github.com/dotnetboost/settings/blob/main/docs/write-notifications.md) |
| Audit or forward every write (and who made it) | [Write notifications](https://github.com/dotnetboost/settings/blob/main/docs/write-notifications.md) |
| Generate a form from the class (list groups, read the schema) | [Discovery endpoints](https://github.com/dotnetboost/settings/blob/main/docs/discovery-endpoints.md) |
| Give admins a web UI to edit settings | [Dashboard](https://github.com/dotnetboost/settings/blob/main/docs/dashboard.md) |
| See every builder option | [Configuration reference](https://github.com/dotnetboost/settings/blob/main/docs/configuration-reference.md) |
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ New here? Start with the [5-minute quick start](../README.md#get-started-in-5-mi
- [Encrypting sensitive values](encryption.md): `[Sensitive]`, AES-256-GCM, key rotation
- [Validation](validation.md): Data Annotations and FluentValidation
- [Change notifications](change-notifications.md): run code when a setting changes
- [Write notifications](write-notifications.md): observe every write — what changed, by whom
- [Write notifications](write-notifications.md): audit every write with it, or observe it after — what changed, by whom
- [Caching](caching.md): cache duration, Redis for multi-server deployments
- [REST API endpoints](rest-api.md): generated endpoints and **how to secure them**
- [Discovery endpoints](discovery-endpoints.md): list the groups, describe one's properties and constraints
Expand Down
2 changes: 2 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
or Redis) Dapper / or custom) or FluentValidation)
MongoDB)
│
ISettingWriteParticipant (inside the write: enlists work that
│ commits with it, or aborts it)
┌───────────┴────────────┐
ISettingChangedHandler<T> ISettingWriteObserver
(typed, one group, (untyped, every group: the
Expand Down
3 changes: 2 additions & 1 deletion docs/change-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ the wrong one is mostly a matter of fighting the data you are handed.

Both are **in-process only**, and both have the same failure policy: **an exception is logged and
swallowed**, so neither can fail a write that has already committed, and one failing handler
never skips the ones after it.
never skips the ones after it. Each failure is also counted on the `DotNetBoost.Settings` meter;
see [Write notifications](write-notifications.md#failure-policy).

Registering both is normal — they are not alternatives.
1 change: 1 addition & 0 deletions docs/configuration-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
| `.UseAesEncryption(key, retiredKeys...)` | Core | Enable built-in AES-256-GCM encryption; retired keys decrypt only |
| `.IgnoreDecryptionFailures()` | Core | Fall back to defaults instead of throwing when a value will not decrypt |
| `.UseCustomEncryption<TEncryptor>()` | Core | Plug in a custom encryptor |
| `.UseWriteParticipant<TParticipant>()` | Core | Enlist work that commits with every write, or aborts it; Entity Framework Core only |
| `.UseWriteObserver<TObserver>()` | Core | Observe every completed write; several may be registered |
| `.UseLoggingWriteObserver()` | Core | Log one line per write — property names, never values |
| `.UseActorAccessor<TAccessor>()` | Core | Supply who is making the change |
Expand Down
141 changes: 135 additions & 6 deletions docs/write-notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,109 @@ the write's own transaction, could fail a write that had already committed, expo
history endpoint, and existed only for Entity Framework Core — the Dapper bootstrap created a
`SettingAudits` table that nothing ever wrote to.

What the library does instead is report every write, and let you decide what to do with it.
A general `DotNetBoost.Auditing` package will implement recording over this hook, the same way
`DotNetBoost.Settings.FluentValidation` implements `ISettingValidator` — port here,
What the library does instead is report every write, through two hooks, and let you decide
what to do with it. A general `DotNetBoost.Auditing` package will implement recording over them,
the same way `DotNetBoost.Settings.FluentValidation` implements `ISettingValidator` — port here,
implementation in a package of its own.

| | `ISettingWriteParticipant` | `ISettingWriteObserver` |
|---|---|---|
| Runs | Inside the write, before the store saves | After the write has committed |
| An exception | Aborts the write | Is logged, counted and swallowed |
| Commits with the write | Yes, what it enlists | No — it runs after |
| Providers | Entity Framework Core | All |
| Good for | An audit trail: anything that must not be lost | Cache signals, webhooks, indexing, logging |

Both receive the same `SettingWrite` for a given write, so an audit entry and a log line share a
correlation id.

## `ISettingWriteParticipant`

Called once inside every write to any settings group, before the store saves it. What it
**enlists** is committed by the store's own save, in the same transaction as the rows, so the
two commit together or not at all.

```csharp
public sealed class SettingAuditParticipant(EfCoreSettingWriteScope scope) : ISettingWriteParticipant
{
public Task OnWritingAsync(SettingWrite write, CancellationToken ct = default)
{
scope.Add(new SettingAuditEntry
{
Group = write.GroupName,
Kind = write.Kind.ToString(),
Actor = write.Actor,
CorrelationId = write.CorrelationId,
Changes = JsonSerializer.Serialize(write.Changes),
OccurredAt = write.OccurredAt
});
return Task.CompletedTask;
}
}
```

```csharp
builder.Services.AddSettings()
.UseEntityFrameworkCore<AppDbContext>()
.UseWriteParticipant<SettingAuditParticipant>()
.Build();
```

`SettingAuditEntry` here is your own entity, mapped on `AppDbContext`, the same context the
settings are on.

### Enlist, never save

- **Add through `EfCoreSettingWriteScope`**, not through a context of your own. The scope wraps
the exact context instance the settings store saves, so what you add goes out in its save. An
entity added to any other context would never be saved, and nothing would tell you. An entity
whose type the settings context does not map throws on `Add`, which aborts the write.
- **Do not call `SaveChangesAsync`**, and do nothing with an effect outside the context — no
messages, no HTTP calls. The write can still fail after every participant has run, on a
concurrency conflict for one, and only what was enlisted is undone. Work that should happen
only once the write has committed belongs in an observer.
- **Enlist standalone entities.** If the write does not commit, each entity you added is
detached again, so the application's next `SaveChangesAsync` on the same context cannot save
it. Related entities reached only through a navigation are not detached with it.

### When a participant fails

**A participant's exception aborts the write**, and reaches the caller unchanged. The store is
not called, the cached copy of the group stays, no observer or change handler runs, and what
earlier participants enlisted is discarded. Participants run in registration order, and the
first to throw stops the rest.

The same happens when the store itself fails after the participants ran: a
`SettingConcurrencyException` discards what they enlisted, and the store takes back its own
rows, so nothing from the failed write is left tracked on the context.

### What the store does with it

A settings write calls `SaveChangesAsync` on the shared context, which saves everything tracked
there — your application's own pending changes included. That has always been true of the
Entity Framework Core store; a participant is what makes it useful.

A clear is the one write that does not normally go through the change tracker: it is a single
`ExecuteDeleteAsync`. When something was enlisted, the store runs the delete and a
`SaveChangesAsync` in one transaction instead:

- inside **your transaction**, when the context has one, which you then commit or roll back;
- otherwise in **its own**, opened inside the context's execution strategy, so it works under
`EnableRetryOnFailure`.

Inside your own transaction, participants and observers have already run by the time you roll
back: an observer can be told about a write your rollback then undid. What a participant
enlisted is undone with everything else.

### Which providers accept participants

Only Entity Framework Core. Dapper opens a transaction of its own inside each write, and MongoDB
uses none, so neither can commit something a participant enlists together with the rows.
`Build()` refuses a participant for either, rather than run it with no atomicity — the mistake
the old Dapper audit table made. A custom store opts in by implementing `IEnlistingSettingStore`
and declaring it through `SettingBuilderGuard.EnsureProviderNotConfigured(builder, name,
supportsWriteParticipants: true)`.

## `ISettingWriteObserver`

Called once after every completed write to any settings group.
Expand Down Expand Up @@ -39,14 +137,23 @@ Several observers can be registered; all of them run, in registration order.
| `GroupName` | The persistence key the rows are stored under |
| `Route` | The group's API route segment |
| `Kind` | `Updated`, or `Cleared` for `ClearAsync` |
| `Changes` | One `SettingChange` per property that actually changed; empty when `Cleared` |
| `Changes` | One `SettingChange` per property that actually changed, or per row a clear deleted |
| `Actor` | Who made the change, or `null` for "not captured" |
| `CorrelationId` | One value per write, so the several changes of one save are identifiable as one edit |
| `OccurredAt` | UTC timestamp |
| `OccurredAt` | UTC timestamp, taken just before the store is called |
| `GroupType` | The settings class, for reading its attributes |

Identify a group by `GroupName`, not by `GroupType`'s name. Moving the class to another
namespace changes its full name but not the rows it reads, so anything keyed on the type name
would split one group's history in two.

Each `SettingChange` is `(PropertyName, OldValue, NewValue, IsRedacted)`. `OldValue` is `null`
when the property had never been stored.

A clear lists what it deleted: one entry per stored row, with the value it held as `OldValue` and
`NewValue` `null`. It reads the group first to do that, but only when a participant or observer
is registered. A row written between that read and the delete is deleted without being listed.

A write that changes nothing raises no notification at all. Change detection compares
`[Sensitive]` values as plaintext rather than as stored ciphertext: AES-GCM draws a fresh nonce
on every call, so an unchanged secret re-encrypts to different bytes each save and a ciphertext
Expand Down Expand Up @@ -86,6 +193,27 @@ deliberately not the one for [`ISettingProjector<T>`](rest-api.md#reshaping-what
which *can* fail a request — a failed notification must not break a committed write, while a
failed projection would otherwise return a wrong response.

So an observer is the wrong place for anything that must not be lost; that is what
[a participant](#isettingwriteparticipant) is for. An observer's failures are counted
rather than surfaced: each one also adds 1 to a counter on the `DotNetBoost.Settings` meter, so
a rate of them shows up on a dashboard instead of only in the logs.

| Instrument | Counts | Tags |
|---|---|---|
| `dotnetboost.settings.write_observer.failures` | Write observers that threw | `dotnetboost.settings.observer`, `dotnetboost.settings.group`, `error.type` |
| `dotnetboost.settings.change_handler.failures` | [Change handlers](change-notifications.md) that threw | `dotnetboost.settings.handler`, `dotnetboost.settings.group`, `error.type` |

The observer and handler tags are the implementation's class name, as in the log line. Every
tag is bounded by your own types, never by a value. To export them with OpenTelemetry:

```csharp
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics => metrics.AddMeter("DotNetBoost.Settings"));
```

The meter is created through the container's `IMeterFactory`, which every generic host
registers, so each host — a test host included — gets its own.

## The stopgap: logging writes

`LoggingSettingWriteObserver` ships in Core so the hook is not an empty port:
Expand All @@ -100,7 +228,8 @@ is exactly the kind of thing that should not reach a log aggregator because a de
on. It is off unless you ask for it.

This is a stopgap, not a trail: there is nothing to query, and nothing enforces retention. For a
real history, implement `ISettingWriteObserver` over your own store.
real history, implement an [`ISettingWriteParticipant`](#isettingwriteparticipant) that records
into your own table.

## Upgrading from `1.0.0-preview.1`

Expand Down
4 changes: 3 additions & 1 deletion samples/SampleApp/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,14 @@
// telemetry — Npgsql's ActivitySource and Meter, so a trace on the dashboard shows the
// SQL a request ran instead of stopping at the HTTP span, and the Metrics
// page carries the connection-pool counters.
// The DotNetBoost.Settings meter is this library's own, not Npgsql's: it counts write observers
// and change handlers that threw, which are otherwise only a log line.
builder.Services.AddHealthChecks()
.AddDbContextCheck<AppDbContext>();

builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource("Npgsql"))
.WithMetrics(metrics => metrics.AddMeter("Npgsql"));
.WithMetrics(metrics => metrics.AddMeter("Npgsql", "DotNetBoost.Settings"));

// ─────────────────────────────────────────────────────────────────────────────
// Storage provider — ALTERNATIVE: SQL Server (Entity Framework Core)
Expand Down
Loading
Loading