Skip to content

Close the API gaps: write observers, discovery endpoints, projectors - #7

Merged
sadeqabuhattem merged 27 commits into
mainfrom
fix/api-gaps-settings
Oct 10, 2026
Merged

sadeqabuhattem merged 27 commits into
mainfrom
fix/api-gaps-settings

Conversation

@sadeqabuhattem

Copy link
Copy Markdown
Member

Closes the API gaps found by reviewing the sample app against the library: the places where a
feature existed but nothing could reach it, or where the shape it exposed was wrong. 25 commits,
each with its reasoning in the message.

The breaking change

The built-in audit trail is gone, replaced by ISettingWriteObserver. It could not be frozen
into 1.0: ChangedBy was the literal string "system" on every entry, ClearAsync deleted a
whole group and recorded nothing, entries were written one round trip at a time after the write
had committed and outside its transaction, GetHistoryAsync was unbounded over a table that only
grows — and it existed only for EF Core, so a Dapper user got a SettingAudits table that nothing
ever wrote to.

Auditing returns as a general DotNetBoost.Auditing package over the new hook — port in Core,
implementation in a satellite package, the way ISettingValidator and
DotNetBoost.Settings.FluentValidation already work.

Migration: implement ISettingWriteObserver, or call .UseLoggingWriteObserver() in the
meantime. Nothing drops an existing SettingAudits table for you.

Added

  • ISettingWriteObserver — one notification per completed write, with the changed properties,
    the actor, a correlation id and a timestamp. [Sensitive] properties arrive redacted.
  • GET /api/settings and GET /api/settings/{route}/schema — nothing enumerated the
    [SettingGroup] classes or described a group's properties, so a "form generated from the class"
    was really generated by guessing. Neither returns a stored value.
  • ISettingProjector<T> / ISettingProjector<T, TProjection> — reshape what GET returns
    without touching what is stored; the typed form lets the endpoint advertise the projection
    instead of the stored group.
  • ISettingSchemaContributor and .UseSchemaContributor(), with a FluentValidation
    implementation that publishes the bounds its validators already enforce.
  • ISettingChangeSignal and .SynchronizeThroughDistributedCache() — a write on one instance
    no longer leaves the others serving stale values for up to CacheDuration. The staleness bound
    becomes ChangeCheckInterval (5s default), not zero; the tradeoff is documented.

Fixed

  • SetAsync(selector, value) could revert another writer's change to a different property. It
    rebuilt the whole model from the cache and wrote whatever differed, so touching property A undid
    someone's edit to property B over a window as wide as CacheDuration. It now writes a single row.
  • GetAsync handed every caller the same mutable object. The cache held the mapped instance, so
    assigning to a property of what you read wrote into the entry every later reader was served from
    — including GET, which then returned the mutated value as though it were stored.

Deprecated, not broken

ISettingDbContext is obsolete and does nothing — delete : ISettingDbContext and the two DbSet
properties. ApplySettingsConfiguration is renamed ApplySettings, with the old name kept as an
[Obsolete] forwarder. Both compile with a warning and go in the next release.

Release

[Unreleased] becomes [1.0.0-preview.2] in the changelog — it held nothing but this branch's
work. The heading is dated 2026-10-10; update it if the release slips before tagging.
Tag v1.0.0-preview.2 on the merge commit to publish.

Verification

Locally, on both net8.0 and net10.0:

Suite Result
Unit 185 passed
API endpoint 51 passed
Provider (SQLite) 54 passed
Integration (Testcontainers: SQL Server, PostgreSQL, MongoDB) 140 passed

dotnet build -c Release is clean with 0 warnings under TreatWarningsAsErrors. Unit-test line
coverage is 81.95%, up from 79.03% — the FluentValidation package went from 67.5% to 100% line
coverage to clear the gate. Branch coverage is 78.28%, so this PR also settles whether the
COVERAGE_THRESHOLD: 80 gate reads lines or branches.

🤖 Generated with Claude Code

sadeqabuhattem and others added 25 commits October 3, 2026 20:45
GetAsync cached the mapped model, so every caller of For<T>().GetAsync()
received the same mutable object. Assigning to any property of it wrote
into the entry every later reader of that group was served from —
including GET /api/settings/{group}, which then returned the mutated
value as though it were stored. SetAsync(selector, value) was mutating
the cached instance for the same reason.

Cache the stored rows and map a fresh model on each read. Skipping the
round trip to the store is what the cache was ever worth; MapToModel is
reflection over a handful of properties.

Two consequences worth knowing, both documented in docs/caching.md: a
[Sensitive] value is decrypted per read rather than once per cache
entry, and a custom ISettingCache is handed Setting[] — so what lands
in Redis is the stored ciphertext rather than a decrypted model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Serving a settings group as anything other than its stored values — a
display name in the request's language, an image id resolved to a URL —
meant standing up a parallel endpoint and leaving the generated one
returning a value the UI never shows. Settings does not need to know
what those other components are; it needs a hook.

ISettingProjector<T>, registered with .UseProjector<T, TProjector>(),
is applied by MapSettingsEndpoints on GET. Three constraints make it
safe to have, and each has a test:

  - read path only, so POST never consults it and a projector cannot
    influence what is stored;
  - never applied to the ETag, which keeps describing the stored
    revision, so If-Match goes on protecting the stored values;
  - no projector registered is the previous response, byte for byte.

Also tag the generated routes "Settings", as the sibling packages do.
app.MapGroup("").WithTags("Settings").MapSettingsEndpoints() — an empty
prefix whose only purpose was to carry a tag — becomes
app.MapSettingsEndpoints().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A consuming AppDbContext implemented ISettingDbContext and declared two
DbSet properties the library mostly never read, then restated in
OnModelCreating an engine the context already knows.

EfCoreSettingStore and EfCoreAuditStore now take a plain DbContext and
reach their entities through Set<Setting>() / Set<SettingAuditEntry>(),
and UseEntityFrameworkCore<TContext> constrains on nothing but
DbContext. It also registers TContext as the scoped DbContext, which is
what lets .UseAuditStore<EfCoreAuditStore>() go on resolving.
ISettingDbContext is obsolete and does nothing; it goes in the next
release.

ApplySettingsConfiguration(DbContext) reads the engine off the context,
so OnModelCreating is one call and switching provider needs no matching
edit. The explicit overload stays for overriding that — a model built
against one engine and migrated onto another. An unrecognised provider
throws naming the overload to use, rather than guessing a column type
the engine cannot hold and surfacing it later as truncated settings.

The whole settings registration a context needs is now that one call,
which is what the test and sample contexts demonstrate. The PostgreSQL
and SQL Server integration contexts use the inferring overload, so both
provider names are mapped against a live server rather than only in a
unit test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Plain single-space declarations, matching how the settings classes are
written in samples/SampleApp.
The brief the preceding four commits work from: a review of
dotnetboost-sample listing what composing Settings, Media and
MultiLingual in application code turned up. Part 1 of 4, covering the
items this repo owns.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The audit trail could not be frozen into 1.0. ChangedBy was the literal
"system" on every entry and never anything else, over a column required
in all four schemas. ClearAsync deleted every row in a group and
recorded nothing, so the most destructive operation in the library was
the only one with no trail. Entries were written one round trip at a
time after the write had committed, each with its own SaveChangesAsync,
outside the write's transaction — and the loop had no try/catch, so an
audit store failure threw on a write that had succeeded, thirty lines
from change handlers whose exceptions are deliberately swallowed.
GetHistoryAsync was unbounded over a table that only grows. And it
existed only for EF Core: the Dapper bootstrap created a SettingAudits
table nothing ever wrote to, so a Dapper user got a table, concluded
auditing worked, and got silence.

Remove it, and leave the port behind: ISettingWriteObserver is called
once per completed write with the group, the per-property diff, the
actor, a correlation id and a timestamp. One notification per write, so
the several changes of one save are identifiable as one edit. ClearAsync
reports too, which is why SettingWriteKind exists. Exceptions are logged
and swallowed, matching ISettingChangedHandler<T> — the two are now
documented as a pair, with the division between typed-per-group and
untyped-every-group spelled out.

[Sensitive] properties arrive redacted with both values null. The
library decides what is sensitive and an observer only learns that it
changed; an observer is precisely what forwards writes elsewhere, so it
should not be where a secret escapes. Redaction follows the attribute,
not whether a value was encrypted at rest.

Actor is nullable and null means not captured, never a placeholder —
that is what made ChangedBy worthless. ISettingActorAccessor supplies
it; the API package ships one reading the signed-in user.

LoggingSettingWriteObserver ships so the port is not empty: one line per
write naming the changed properties, never their values.

Nothing is registered against DbContext any more either. That existed
only so EfCoreAuditStore could resolve, and a library should not claim a
framework type it does not own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The call rebuilt the whole model from the cache and put it through the
group write, which compares every property against fresh store rows and
writes whatever differs. So a property another instance had changed
while this one's cache was warm read back stale, counted as a
difference, and was written back — a call that meant to touch property
A reverted someone's edit to property B, over a window as wide as
CacheDuration rather than a round trip.

Build and write a single row instead. Properties the caller did not
name are not part of the write, so there is nothing to revert. The
per-property row construction is now shared with the group write rather
than duplicated, so the encryption and change-detection rules cannot
drift apart between the two paths.

Validators still see the whole group — they take a model, not a
property — built from a fresh read, so untouched properties carry their
stored values rather than a blank instance's defaults. Skipping that
would have made the selector overload a way around every registered
validator.

The XML doc understated the problem twice: it said "reads the current
group" when the read was cached, and framed the damage as landing on
the property being written. Corrected.

The regression test gives the two managers separate caches, which is
what two replicas have and the only arrangement where the bug is
reachable — one shared cache would let the other writer's eviction
clear the stale copy before it could do harm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A write evicted the local IMemoryCache entry and nothing else, so every
other instance went on serving the old values until its own entry
expired — up to CacheDuration, ten minutes by default. "Change it,
reload, done, no redeploy" is this library's central claim and it
silently stopped being true at two replicas, in a repo that ships an
Aspire AppHost.

ISettingChangeSignal mirrors ILanguageChangeSignal in MultiLingual: a
version token in a store every instance can reach, published on each
write and compared by readers. Per group here rather than global, since
a write to one group should not make every other group reload.

Publishing is an ISettingWriteObserver rather than a second call site
inside the manager, so ClearAsync propagates for free.

The staleness bound is ChangeCheckInterval, 5 seconds by default, and
the docs say so rather than implying propagation is instant — what this
buys is a bound of seconds instead of minutes. An unreachable signalling
store is logged and treated as unchanged: a signal is an optimisation
over the cache duration, so a Redis outage should cost a slower reload,
not an error.

The default signals nothing, so a single-instance application needs no
configuration and behaves exactly as before — the no-op always reports
a null token, which always matches, so every cache hit is served as it
was.

Probes — the token a cached copy was loaded under, and when it was last
checked — are keyed by the cache instance. That is both correct (a probe
describes one cache's entry) and what lets a test model two instances
honestly, with separate caches and a shared signal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nothing enumerated the [SettingGroup] classes and nothing described a
group's properties, so a "form generated from the class" was really
generated by guessing at the shape of a JSON response. The consuming
sample shows the cost: its dashboard hardcodes the route, the C# class
name and the numeric bounds — bounds that exist nowhere in C# and are
therefore enforced nowhere on the server.

GET /api/settings lists the groups with their route, persistence key and
whether they carry [Authorize]. GET /api/settings/{route}/schema
describes one group's properties: name as the GET body spells it, type,
default, and whether it is [Sensitive].

Neither returns a stored value, and several tests exist to keep it that
way. The default is withheld for a [Sensitive] property: a compile-time
default on a secret is a value like any other, and these endpoints are
meant to be safe to expose more widely than values are. The schema sits
inside its group so it inherits the group's [Authorize] — a group's
endpoints should not disagree about who may look at it.

Constraints are the valuable half for a generated form, and
ISettingValidator cannot supply them: it answers whether a value is
valid, never what would be. ISettingSchemaContributor fills that, and
the existing UseFluentValidation(assembly) call now registers an
implementation reading FluentValidation's own descriptor, so the schema
publishes the bounds your validators already enforce. A rule it cannot
read — a Must(...), a comparison against another property — is skipped
rather than guessed at: a wrong published bound is worse than a missing
one, because a client would believe it.

The rule for which properties the engine stores now lives in one place,
SettingSchema, shared with SettingManager. A schema that listed a
property a write ignores would be worse than no schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A projector that throws fails the request, deliberately unlike
ISettingChangedHandler<T> and ISettingWriteObserver, whose exceptions
are logged and swallowed: a failed notification must not break a write
that has already committed, whereas a failed projection means the
response would be wrong, and a wrong response must not be hidden.

Behaviour unchanged — it was never written down. Covered by a test
rather than only asserted in a comment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Missed when the SQL Server twin was updated: the schema test still
required the audit table the bootstrap no longer builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The "EF Core store takes a plain DbContext" bullet was written before
the audit trail was removed and never updated. It named EfCoreAuditStore
and Set<SettingAuditEntry>(), both listed as deleted in the Removed
section of the same release, and ended by stating that
UseEntityFrameworkCore<TContext> registers TContext as the scoped
DbContext so .UseAuditStore<EfCoreAuditStore>() keeps resolving — which
the bullet directly above it says was removed.

Drop those two claims. The bullet above is unchanged, and the audit
types now appear in the Unreleased section only where its Removed entry
announces their deletion.

The [1.0.0-preview.1] section still describes the audit trail, which is
correct history for a shipped release.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The brief the preceding commits worked from. Every item it assigned to
this repo has landed, so it is history rather than a plan; it stays
readable at 8edd75a.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First-match-wins was wrong, and the comment justifying it cited a
precedent that does not transfer. First-match is right for
ISettingValidator because running two validators over one property risks
rejecting or reporting a value twice. Constraints are not a decision —
they are additive facts about a property, and two contributors
describing different aspects of one group is the normal case.

It already blocked a consumer: publishing `translated` for properties
carrying an attribute from another package would have replaced the
FluentValidation contributor, so the group gained `translated` and lost
min, max, pattern and required — with the survivor decided by
registration order, which nothing documented or controlled.

Merge two levels deep, property then constraint name. On a collision the
first registered wins and the clash is logged at Warning naming both
contributors, the group, the property and the constraint; taking either
silently would leave the published schema dependent on registration
order, which is the defect being fixed.

Each contributor stays individually wrapped. With several registered
that matters more, not less: one failing must not cost the others their
constraints, and there is now a test for exactly that, since it is what
silently regresses if the try/catch is ever hoisted out of the loop.

The single-contributor case — every current caller, including
UseFluentValidation — hands back the contributor's own dictionary with
nothing merged and nothing allocated, and is not behaviourally changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ISettingSchemaContributor was the only port on SettingBuilder with no
way to register it from the chain. Nobody hit the gap because
DotNetBoost.Settings.FluentValidation registers its own internally — but
a consumer publishing a constraint of its own, which is exactly what the
port exists for, had to drop out of AddSettings() and call
AddSingleton<ISettingSchemaContributor, T>() by hand.

TryAddEnumerable with a type descriptor: several different contributor
types all register, which is the point now that the schema merges them,
while a repeat of one type is deduped. A contributor only describes, so
a duplicate would describe the group twice and the merge would read the
second pass as a collision with itself. Deliberately unlike
UseWriteObserver, where two registrations are two observers because an
observer does something.

The factory overload uses plain Add: two factories are not comparable by
implementation type, so deduping them would silently drop one.

Registration order decides which value survives a collision, so the XML
doc says so rather than leaving it to be discovered.

Both registration choices are covered by tests that fail without them —
plain Add breaks the dedup case, service-type TryAdd breaks the
distinct-types and composes-with-FluentValidation cases.

TestApp gained a SettingBuilder hook, so these run through the chain an
application actually writes, and warning capture, so the duplicate test
can assert a clash was *not* logged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
UseProjector used AddScoped, so a second registration for one group was
accepted and the last silently won — the other was never constructed,
and the response was quietly missing half its shape.

That is never what anyone means. Projectors do not compose: ProjectAsync
takes the group, so a second has nowhere to receive the first's output.
A group needing both translations and media wants one projector doing
the join, not two registrations.

Build() now rejects it, beside the checks for duplicate group names and
routes, naming the group and both implementations. Descriptors
registered by factory or instance carry no ImplementationType and are
counted individually rather than folded together — treating two
unknowns as one would silently pass the case the guard most wants to
catch.

The same implementation type twice is collapsed rather than reported:
an idempotent mistake, not an ambiguity, matching the mercy
UseSchemaContributor already extends.

ReplaceProjector<TSettings, TProjector> is the deliberate-override path
the guard makes necessary, and the error message names it so the error
says what to do instead.

This is a guard, not a guarantee, and the XML doc says so: registrations
added after Build() still win by last-wins, which keeps the
test-override seam open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
.Produces(200, type) named the stored group, which is wrong for every
projected group — and the library could not do better on its own: a
projector is resolved from the request scope, long after the route was
mapped. The docs admitted it in prose and told the reader to rewrite the
document themselves, which dotnetboost-sample has been doing with an
IOpenApiOperationTransformer.

ISettingProjector<T, TProjection> names the type instead. It sits on the
projector rather than as a third argument to UseProjector so the
compiler checks it; a third generic argument would be a promise the
author could get wrong, and catching that would need a runtime check.

A default interface member adapts the typed member to the
object-returning one the generated endpoint already calls through
reflection, so implementing the typed interface is all an author does.
An existing ISettingProjector<T> keeps compiling and keeps declaring the
group.

The declared type travels to the endpoint as a keyed singleton under the
group type: one per group by construction, with no ordering rule to
define. Every registration path writes the key, with the group as the
value when the projector is untyped, so a later ReplaceProjector cannot
leave a stale projection type behind. A projector declaring two
projections for one group throws at the registration call, naming both,
rather than surfacing as a LINQ failure.

This documents the response; it does not change it. The body is byte for
byte what the untyped interface produced — asserted, so the default
interface member is proven to dispatch — and POST still accepts the
group, because advertising the projection as a request body would
promise a write the API refuses.

Tests assert IProducesResponseTypeMetadata off Endpoint.Metadata rather
than generating an OpenAPI document: AddOpenApi is .NET 9+ and this
suite also runs on net8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1904708 claimed that every registration path writes the projection
declaration, "so a later ReplaceProjector cannot leave a stale
projection type behind" — and nothing held it. Deleting the
DeclareProjection call from ReplaceProjector left all 174 unit and 49
API tests green, which makes it exactly the line a refactor drops in
silence.

Two cases, one per direction: a typed projector replaced by an untyped
one declares the group, and an untyped one replaced by a typed one
declares the projection. Both fail without that call.

Asserted on the endpoint's declared 200, which is what a consumer's
OpenAPI document is built from. A test on the keyed descriptor would
pass even if RegisterGet stopped reading it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Media exposes modelBuilder.ApplyMedia() and MultiLingual exposes
modelBuilder.ApplyMultiLingual(this). Settings was the only one of the
three naming the mechanism rather than the library, and in a consuming
AppDbContext the three calls sit on adjacent lines, so the odd shape
read as if it meant something. It did not. preview.2 is the last cheap
moment to fix it.

Additive, using the pattern ISettingDbContext already set: both
overloads are renamed, and the old names stay as [Obsolete] one-line
forwarders that go in the next release. An existing context keeps
compiling with a deprecation warning — confirmed to be a warning rather
than a break by building without this repo's warnings-as-errors.

ResolveProvider's exception told the caller to use
ApplySettingsConfiguration(DatabaseProvider), and the test asserted that
literal, so both moved together; otherwise the test would have gone
green on a string nothing throws.

The forwarders are covered: a context on the old name builds the same
model as one on the new name, for every provider and for the
context-inferring overload. CS0618 is suppressed in that one region and
nowhere else.

CHANGELOG records the rename as a new entry. The existing entries
describing what preview.2 shipped are left alone — editing them would
make the history lie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
c2c1c84 left the Unreleased section introducing
ApplySettingsConfiguration(DbContext) under Added and renaming it under
Changed, held together by a "this supersedes the naming used in the
Added entry" clause. Read as release notes, the release adds an API and
renames it in the same breath.

That followed from treating those entries as shipped history. They are
not: [Unreleased] runs from line 6 to 220, and the only released
sections are 1.0.0-preview.1 and 0.1.0. Nothing in Added had shipped
under any name, so there was nothing to preserve and no rename to
record — the entry can simply say ApplySettings(DbContext).

What stays in Changed is the part that is a real change.
ApplySettingsConfiguration(DatabaseProvider) is the only member
1.0.0-preview.1 published, confirmed against that package's XML docs, so
it is the one an [Obsolete] forwarder exists for and the one the entry
now talks about.

Also updates the EF-store entry, which still described a consuming
context's registration as an ApplySettingsConfiguration call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
An [Obsolete] forwarder exists so code already calling the old name
keeps compiling. No such code can exist for ApplySettingsConfiguration
(ModelBuilder, DbContext): that overload has never been in a published
package. It is new in this unreleased work, and the only place it ever
existed under the old name was a locally packed preview.2 whose one
consumer, dotnetboost-sample, is already on the new name.

So the alias was a deprecation warning aimed at code that cannot exist,
plus a commitment to a removal note for a method with no users.

The (ModelBuilder, DatabaseProvider) forwarder is the opposite case and
stays: it shipped on nuget.org in 1.0.0-preview.1, and a real consumer's
OnModelCreating calls it. Its tests stay with it, as does the CS0618
suppression they need.

CHANGELOG already described exactly one forwarder as of aed285f, so it
is correct as written and left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Unit-test line coverage was 79.03%, under the 80 the CI coverage gate
names in COVERAGE_THRESHOLD. The whole shortfall sat in one package:
DotNetBoost.Settings.FluentValidation, at 67.5%.

FluentSettingValidator<T> had no coverage at all. It is the adapter that
makes a FluentValidation rule reach SetAsync and the REST API — Core
knows only the untyped ISettingValidator — and the error shape it
produces is what the API serialises into a 400 body, so it is contract
surface rather than an implementation detail. Covered: CanValidate
matching only its own type, a valid model reporting no errors, errors
keyed by property name, two rules failing on one property grouping into
one key with two messages, and the type-mismatch path returning a
failure that names both types instead of throwing from inside
FluentValidation.

The schema contributor's tests exercised Length(min,max),
InclusiveBetween, Matches, NotEmpty, GreaterThanOrEqualTo and LessThan,
leaving four arms of Collect's switch unread. The new cases pin the
distinctions a client would otherwise get wrong: an exact length is both
bounds at once rather than falling through to the general length rule, a
one-sided length rule leaves the other bound absent rather than
publishing an invented floor, and LessThanOrEqualTo/GreaterThan stay
separate from their inclusive counterparts — rendering exclusiveMax as
max would let a client accept the boundary value the server refuses.

AddFluentValidationSettings, the standalone counterpart to
UseFluentValidation, is covered along with its null guards.

Bridge() returns the concrete FluentSettingValidator<T> rather than
ISettingValidator because CA1859 is an error here; the members it calls
are the interface's either way.

The package is now at 100% line coverage and the total is 81.95%. Branch
coverage is 78.28% and still under 80, which matters only if the gate
reads branches — worth confirming on the next run, since minimumCoverage
may not be an input the ReportGenerator action recognises at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README was updated for the quick start, the write observer and the
discovery endpoints when those landed, but four statements were left
behind by later commits in the same branch.

The worst contradicted the quick start twelve lines above it: "EF Core
needs two extra DbSets on your context", when 4b4cc73 removed both the
marker interface and the DbSet requirement, and the audit entity one of
those two DbSets pointed at no longer exists. storage-providers.md has
said "no interface and no DbSet properties" since that commit; the
README landing page still told a reader the opposite.

Projectors had no README presence at all — not a table row, not a
mention — though ISettingProjector<T> is one of this branch's larger
additions and rest-api.md has a section on it. A reader looking for "can
I change what GET returns" found nothing and had to open the REST API
guide on spec.

The caching paragraph said a write "clears the cache on that server" and
sent multi-server readers to the caching guide, which is true but now
understates the answer: SynchronizeThroughDistributedCache() is a single
line and the guide's recommended path. Naming it in the one place a
reader is already thinking about several instances seems worth two
lines. The table row named Redis specifically, which is now the
alternative rather than the recommendation.

And MapSettingsEndpoints() maps the group list and schema by default
(includeDiscovery: true), not just the group's GET and POST as its
inline comment claimed.

CHANGELOG: [Unreleased] held nothing but this branch's work — it is
_Nothing yet._ on main — so it becomes [1.0.0-preview.2] with today's
date, and a fresh empty [Unreleased] takes its place in the form this
file already uses. The new section opens the way preview.1's does,
naming what a reader upgrading needs to know first: exactly one break
(the audit trail), and two deprecations that compile with a warning.
Those are counted from the entries below rather than from memory —
ISettingDbContext and ApplySettingsConfiguration are warnings, not
breaks, which c2c1c84 and e8ef8d8 established deliberately.

Every new public name in the branch appears somewhere in the section:
checked ISettingChangeSignal, SynchronizeThroughDistributedCache,
UseChangeSignal, ISettingProjector, UseProjector, ReplaceProjector,
ISettingSchemaContributor, UseSchemaContributor, ISettingWriteObserver,
UseWriteObserver, ISettingActorAccessor, ChangeCheckInterval,
ApplySettings and includeDiscovery.

Every docs link and anchor in the README resolves against the tree,
including the two new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Removing the audit trail left the EF model with a single entity —
ApplySettings configures exactly one ToTable("Settings") — but three
sentences still described two tables.

docs/storage-providers.md opened its EF Core section with "add the two
settings tables to your DbContext" and then, forty lines later, said the
migration "creates the `Settings` table". The README's quick start had
the same mismatch in a code comment it gained in this branch: 4b4cc73
rewrote that comment to drop the interface and the DbSet properties and
kept the word "two". A third, nearby, said the bootstrap creates "the
settings tables".

The Dapper and MongoDB sections were already right, and the one
remaining mention of two DbSet properties stays: it is the preview.1
upgrade note, and a reader upgrading really does have two to delete.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread tests/DotNetBoost.Settings.UnitTests/FluentSettingValidatorTests.cs Fixed
Comment thread tests/DotNetBoost.Settings.UnitTests/SinglePropertyWriteTests.cs
Comment thread src/DotNetBoost.Settings.API/SettingsMinimalApiExtensions.cs
Comment thread src/DotNetBoost.Settings.Core/Services/SettingManager.cs Fixed
Comment thread src/DotNetBoost.Settings.Core/Services/SettingManager.cs Fixed
Comment thread src/DotNetBoost.Settings.Core/Services/SettingManager.cs
sadeqabuhattem and others added 2 commits October 10, 2026 21:10
The job passed minimumCoverage to ReportGenerator-GitHub-Action, which
has no such input. GitHub logged

  Unexpected input(s) 'minimumCoverage', valid inputs are ['reports',
  'targetdir', 'reporttypes', ...]

and ignored it, so for the whole life of the workflow this job generated
an HTML report, uploaded it and exited 0 no matter what the coverage
was. COVERAGE_THRESHOLD: 80 was decorative. The ruleset on main lists
"Coverage Gate" as a required check, which made it worse than having no
check at all: coverage could fall to single digits and every PR would
still show a green tick next to the word "Gate".

The report step keeps its job and gains a Cobertura output, which the
new step reads. A merged report is the point: the unit tests run on
net8.0 and net10.0, so the artifact holds two coverage files describing
the same source. Summing them double-counts and either one alone misses
what only the other compiles — net10.0 by itself reads 81.95% line,
while the merge of both is 81.55% over 927 coverable lines.

Line coverage only, at the existing 80. Branch coverage is 75.73% and
gating on it today would fail the build over code this change is not
about; it is printed and written to the step summary so the number stays
in view, and raising it is a deliberate follow-up.

The three ways this step could wrongly pass are all closed, since
wrongly passing is the bug being fixed: a missing merged report, a
missing line-rate attribute and an unparseable one each fail the job
with an ::error:: rather than defaulting to zero or to "fine". Verified
by running the step's own script against the real merged report — 80
passes at 81.55%, 95 fails, a deleted report fails, and a line-rate of
"oops" fails.

The artifact upload moves to if: always() so a build that fails on
coverage still carries the line-by-line report, which is exactly when
someone wants it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
MapToModel throws SettingDecryptionException on purpose, and says why at
the throw site: falling through "would leave the property on its default
value, so a rotated-away key would surface as an app running on default
credentials rather than as an error."

Both write paths then caught it and did exactly that. Three bare
catches — one in the group overload, two in the single-property one —
read `catch { previous = new T(); }`, so a stranded value produced a
before-image of compile-time defaults. Every [Sensitive] property
compared as changed, which meant a write to one unrelated property
rewrote all of them under the current key, told ISettingWriteObserver
they had changed, and discarded the values nobody could read. The
IgnoreDecryptionFailures() opt-out was bypassed, since the catch did not
consult it. And unlike the observer catch at the end of the same file,
these logged nothing at all: the only symptom was a secret quietly
becoming "DEFAULT-DO-NOT-USE".

The read path was always right — GetAsync calls MapToModel uncaught —
so this was the write path disagreeing with the read path about the same
value.

Narrowed rather than removed: an exception filter lets
SettingDecryptionException through while other mapping faults still fall
back to defaults, so a write cannot be blocked by a conversion quirk,
but they now log (event 1009) instead of vanishing. The two calls in the
single-property overload collapse into one try — they map the same rows
and only need to be separate instances, which the comment now says.

Three tests, in the class whose summary already stated this rule for
reads. The two asserting the throw fail against the previous code with
"No exception was thrown", and both verify no row was written; the third
pins the opt-out, and passes either way by design.

CodeQL flagged these as "Generic catch clause" on PR #7, which is how
they were found — filed as style, and a bug underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sadeqabuhattem
sadeqabuhattem merged commit 0f2ef53 into main Oct 10, 2026
10 checks passed
sadeqabuhattem added a commit that referenced this pull request Oct 10, 2026
Code scanning posts findings as pull request review comments, and the
ruleset on main requires every conversation resolved, so each PR arrived
with the merge button already blocked by whatever the quality suite had
to say. #7 carried eleven such threads: three were real and fixed, and
the other eight were deliberate code or false positives dismissed by
hand — three obsolete-method calls inside a CS0618 pragma that exists to
exercise the obsolete forwarder, two casts that are load-bearing for
Task.FromResult's inference, the observer catch documented as
logged-and-swallowed, a Where suggestion and a string concat in a test
helper. Seven of the eight were in tests/.

Selected by omitting `queries` rather than naming it: that is CodeQL's
default suite, the security-focused one. There is no pack named
`security`, and naming a non-existent suite fails init outright instead
of falling back.

This narrows what runs rather than what is reported, so it is a real
loss worth recording: the bare-catch bug fixed in 0f2ef53 — a write
replacing an undecryptable secret with its compile-time default — was
found by a quality query, not a security one. Against that, eight of
eleven findings were noise, the friction landed on every PR while the
one genuine catch was a one-off, and the compiler already enforces more
than the quality suite does (warnings-as-errors, AnalysisLevel
latest-recommended).

To bring the quality queries back without the blocking, the shape is a
codeql-config with query-filters excluding the noisy rules, or
paths-ignore for tests/.

Verified on #8 itself: zero review threads, against eleven on #7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sadeqabuhattem
sadeqabuhattem deleted the fix/api-gaps-settings branch October 10, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants