Skip to content

MP Core 0.10.0

Choose a tag to compare

@panahister panahister released this 12 Oct 12:06

MP Core 0.10.0

A minor version: three behaviours change, CurrentActor.AuthenticatedAt, a second call of
AddMPCoreBearerAuthentication, which now fails, and MP Core's own messages, which are English only. It follows
0.9.3.

Moving from 0.9.3

CurrentActor.AuthenticatedAt is when the person authenticated, and null when the token does not say.
It is read from auth_time only. Before, a token without auth_time gave its issue time (iat) instead, so
a token refreshed long after the person signed in looked freshly authenticated. What to do:

  • Code that judged how recently a person signed in keeps reading AuthenticatedAt, or calls
    IsAuthenticationFresh(maximumAge, now). A null AuthenticatedAt is never fresh; with Keycloak, an access
    token carries auth_time, and a refreshed one keeps the original.
  • Code that wanted the issue time reads the new IssuedAt.

Nothing else to do for a host with one issuer.

A host that called AddMPCoreBearerAuthentication twice, once per issuer, never worked: the second scheme
kept no authority and no issuer. It now fails at that second call. Replace both calls with one call of
AddMPCoreBearerIssuers, as below.

MP Core's own messages are English only. English is MP Core's only built-in language and its default;
every product decides its own languages in its own repository (ADR-012, amendment of 2026-10-08).
MPCore.Localization no longer carries the Persian translation of MP Core's keys (fa), which 0.9.0 to
0.9.3 shipped. A product that relied on it now receives MP Core's English text for those keys in fa. To
keep the Persian texts, the product supplies them itself:

  1. Copy src/MPCore.Localization/Resources/MPCoreMessages.fa.resx from the 0.9.3 source (tag v0.9.3)
    into the product as Resources/<Product>Platform.fa.resx. The keys of 0.10.0 are the keys of 0.9.3.
  2. Add an empty neutral Resources/<Product>Platform.resx and a marker class <Product>Platform beside
    them.
  3. Register it: AddMPCoreMessageCatalog(catalog => catalog.AddResources<<Product>Platform>()), next to the
    modules' resource files. At the product's precedence it wins over MP Core's English in fa.
  4. Keep fa in SupportedCultures of both transports. A product that serves it instead of English also
    makes it the DefaultCulture of both transports and of the catalog.

The language guide gives each step with placeholders and lists every key.

New

Package What it adds
MPCore.Security.AspNetCore Several token issuers. AddMPCoreBearerIssuers(issuers => issuers.Add("<scheme>", options => ...)): one bearer scheme per issuer, each with its own authority or metadata, issuer, audiences and keys and every rule of ADR-007 section 6, behind a default scheme (Bearer) that picks the issuer a token names. A token is checked only against the keys of that issuer; a token that names no configured issuer is refused with 401. Startup fails when two schemes name one issuer. ADR-016
MPCore.Security.AspNetCore A second token as evidence. IMPCoreBearerTokenValidator.ValidateAsync(token) validates a token of any configured issuer with that issuer's own parameters, keys refreshed from its metadata, and returns the validated issuer, the scheme and the claims, or a fixed reason. The token never becomes CurrentActor and is never logged. Registered by both AddMPCoreBearerAuthentication and AddMPCoreBearerIssuers

| MPCore.Security.Abstractions | SubjectEvidence, a bounded record of the person behind a service's call (subject, session, issuer, authentication time), and ISubjectEvidenceAccessor |
| MPCore.Security.AspNetCore | A person's token as evidence beside the service's identity. Calling side: AddMPCoreSubjectEvidence() on a client's builder carries the token of the user being served in x-subject-token, never as the call's credential, over HTTPS only. Called side: AddMPCoreSubjectEvidenceValidation(...) and UseMPCoreSubjectEvidence() admit only listed services, validate the token with the host's issuer rules, azp, typ, sid and auth_time age, remove the header before any application code, and expose SubjectEvidence. A command requires it with RequireSubjectEvidence("<issuer scheme>"); a refusal is 403 / PermissionDenied. ADR-014, addendum of 2026-10-08 |

| MPCore.Security.AspNetCore | Mutual TLS between services. AddMPCoreMutualTls(...): every TLS listener requires a client certificate whose chain ends at a configured authority (never the system store), valid now and for client authentication, with a listed workload name (URI or DNS subject alternative name), checked at the handshake; revocation as configured. RequireWorkloadCertificate() on an endpoint, with UseMPCoreMutualTls(), refuses a request without one with 401 / Unauthenticated. UseMPCoreCertificateForwarding() reads a certificate a proxy forwards in X-Client-Cert, only from TrustedProxies; for a request from a trusted proxy the client certificate is the forwarded one or none, never the certificate of the proxy's own connection. The certificate never becomes the current actor. ADR-017 |
| MPCore.Resilience.Http | AddMPCoreClientCertificate(...) on a client's builder: presents a certificate from PEM files or one the host loads, and trusts only the configured authorities for the called service's certificate |
| MPCore.Templates | Security:MutualTls in appsettings.json, off by default; the generated host calls AddMPCoreMutualTls when it is on, and has UseMPCoreCertificateForwarding() and UseMPCoreMutualTls() in its pipeline, which do nothing while it is off |

| MPCore.Application | A value that never prints. SensitiveValue: ToString(), interpolation, records, exception messages, System.Text.Json and the debugger all show ***; the value leaves only through Reveal(); equality is in constant time; a JSON value that is not a string is refused with a JsonException, so a malformed request is answered with 400. SensitiveMessageTypes: types whose objects MP Core's processors mask whole, a service of the host's container (one per host, never shared by hosts of a process); AddMPCoreSensitiveMessageTypes(params Type[]) registers it and adds types. ADR-018 |
| MPCore.Observability | The name processors also mask a dotted name with a sensitive segment and sensitive names inside a nested collection; a SensitiveValue and an object of a type in the host's SensitiveMessageTypes are masked whole (the processors take the registry from the container; their constructors without it mask no object by its type). The name list is unchanged. The package now references MPCore.Application |
| MPCore.Transport.Grpc | AddMPCoreSensitiveMessages("package.Service") on the gRPC builder: the request and response messages of a named service are masked whole in logs and traces, from host start: the types are read from the mapped endpoints when the host starts, before it listens, so a message logged from the application-started callback or by a handler is masked with no call needed first. A request object logged before that, by a hosted service that starts before the fill or by code between Build and Run, is printed whole. The host fails to start when a name is that of no mapped service |
| MPCore.Observability | A console sink after the redaction. MPCoreObservabilityOptions.EnableConsoleLogExporter (off by default) writes each log record to standard output, after the sensitive-value processor: the time, level, category, event id, formatted message and exception, no attributes or scopes. A host that clears the logging providers keeps its logs on the console. Additive |
| MPCore.Templates | The generated host prints through the pipeline only. Program.cs calls builder.Logging.ClearProviders() before AddMPCoreFoundation, and Observability:EnableConsoleLogExporter (true) keeps the logs on the console. The console, debug and event-source providers ASP.NET Core adds printed a protobuf request of a sensitive service whole, a one-time code included. Seen failing: a generated host is built, run and called, and its console shows Verify ***; before the change it showed the phone and the code. ADR-018, section 4 |

| MPCore.Security.Abstractions | CurrentActor.IssuedAt (iat); CurrentActor.IsAuthenticationFresh(maximumAge, now), never fresh without an authentication time; CurrentActor.AdditionalClaims, a read-only map of allowlisted claims, at most 16 of at most 256 characters; CurrentActorBuilder.IssuedAt and AdditionalClaims |
| MPCore.Security.AspNetCore | ActorClaimMappingOptions.AdditionalClaims: the allowlist of claims MP Core does not map, such as acr. A claim is exposed only when the token carries it once, with a bounded value; startup fails on more than 16 |

| MPCore.Resilience.Http | Token introspection (RFC 7662). AddMPCoreTokenIntrospection(...) and ITokenIntrospector.IntrospectAsync(token): posts the token to the introspection endpoint, named or read once from discovery, authenticated with the client's own credentials (RFC 7662, section 2.1), over HTTPS only. It fails closed: an error, a timeout, an answer that is not a JSON object, an active other than true, or an expired token are all inactive. The result carries Active, Subject, SessionId, ClientId and ExpiresAt, never the token. No cache by default; with MaximumCacheDuration, an active answer is kept no longer than that nor than the token's exp, and an inactive answer is never kept. No token or secret is logged |

| MPCore.Security.Abstractions | IResourceAuthorizer and ResourceDecision: the port a product implements to decide whether the current actor may act on a resource key |
| MPCore.Security.AspNetCore | Resource keys. AddMPCoreResourceKeys(...); RequireResourceKey("<key>") on a REST endpoint and [ResourceKey("<key>")] on a gRPC service or method; ExemptFromResourceKey(). The request is denied when no IResourceAuthorizer is registered, when it denies or does not know the key, throws, or exceeds ResourceKeyOptions.Timeout. With resource keys on, a host whose endpoint declares neither a key nor an exemption fails to start. ADR-007 addendum |

| MPCore.Templates | Module rules as tests. A generated modular monolith carries ModuleRulesTests: for every module listed in HandlerAssemblies, its Domain folder depends on neither its other layers nor a provider, its Application folder on neither its Infrastructure folder nor a provider, and no module references another module's main project. While no module is listed, each rule is reported as skipped, with its reason, and not as passed: a rule that walks an empty list has checked nothing. In both shapes, a query handler takes no IUnitOfWork and no publisher (QueryRules). NetArchTest.Rules is referenced by both shapes |
| MPCore.Localization | A product's own culture in English's place, proved: with its culture as the only supported one and the default of both transports, and its texts for MP Core's keys, every MP Core message reaches a caller that names no language, or one the product does not serve, in that culture, over REST and gRPC. No API changes: HttpFailureOptions, GrpcFailureOptions and MessageCatalogOptions already carried SupportedCultures and DefaultCulture. The language guide |
| MPCore.Templates | The identity provider administered by a governed adapter (ADR-019, the owner's decision of 2026-10-09). The skills no longer refuse a backend that administers the identity provider; they allow it in the one backend the owner names, and mpcore-apply-security states its rules and steps: a closed catalogue of typed commands with no pass-through, its own service identity (AddMPCoreServiceIdentity) with only the provider roles the catalogue needs, every caller authorized for every command, every write audited with IBusinessAuditRecorder (refused and failed attempts too), no administration token in any log, trace, audit record or response (SensitiveValue), and every write idempotent: six rules. Login, signup, OTP, password reset and password change stay out of every generated host. The generated README.md, Program.cs and docs/architecture.md say the same. ADR-019 states which lines of ADR-007 it amends. No package and no API change |
| MPCore.Templates | What a module owns, as tests. ModuleRulesTests also checks that each module maps its tables into one schema of its own, that no foreign key crosses a schema (read from the Entity Framework model, without a database), that an interface of a Contracts project that writes declares its reason, and that a handler takes only its own module's repositories. The module guide gives, as a checklist, what moving a module to a service costs |
| MPCore.Application | CrossModuleWriteAttribute(reason), in MPCore.Application.Modules: the reason a Contracts interface that writes is not a message. Additive |

How to consume several issuers:

builder.Services.AddMPCoreBearerIssuers(issuers =>
{
    issuers.Add("staff", options =>
    {
        options.Authority = builder.Configuration["Security:Issuers:Staff:Authority"];
        options.ValidAudiences.Add("orders-api");
    });
    issuers.Add("partners", options =>
    {
        options.Authority = builder.Configuration["Security:Issuers:Partners:Authority"];
        options.ValidAudiences.Add("orders-api");
    });
});

How to run exactly one commit of main: every package records the commit it was built from, in its nuspec
and as +<commit> in every assembly's informational version. Copy eng/consumer/MPCore.PinnedCommit.targets
into the consuming repository, import it from Directory.Build.targets, and set MPCorePinnedCommit to the
full commit: the build fails when a restored MP Core package records another. docs/guide/packages.md,
"Which commit a package was built from".

Where What it adds
Continuous integration eng/check-version-moved.sh: fails when shipped code changed since the published version that the source still declares. It has a self-test, and checks out the whole history
release.yml A prerelease of a main commit, <declared>-main.<n>, n being the commit's place in main's history; refused from any other branch and for any other suffix. The version check, the commit check of the release gate (MPCORE_EXPECTED_COMMIT) and the consumer check run before anything is published
eng/verify-release-artifacts.sh Every package records one source commit, and every assembly carries +<commit>; self-test case 13 is a package that records another commit. A prerelease of main is verified with the declared version embedded in its template and CLI
eng/consumer/MPCore.PinnedCommit.targets, eng/verify-consumer-pin.sh The consumer-side check, and its proof against packed packages in a throw-away NuGet cache
eng/restore-api-baseline.sh Fails when a package listed as new in the cohort is published at the baseline version, which switched its API validation off in silence

Changed

What Now
The cohort version moves in every place it is declared: first to 0.9.4, because 0.9.3 was published from bd41bd3 and shipped code changed after it, then to 0.10.0 for the change of AuthenticatedAt. The API baseline stays 0.9.3
CurrentActor.AuthenticatedAt from auth_time only; null without it. It fell back to iat
A second call of AddMPCoreBearerAuthentication throws InvalidOperationException that names AddMPCoreBearerIssuers. Before, it registered a scheme without an authority, and the audiences of both calls accumulated
MPCore.Localization carries MP Core's texts in English only, in its neutral resource file, and no satellite assembly. Package validation against 0.9.3 passes without the satellite
The release gate rejects a package that carries a satellite assembly, and a template that carries a culture-specific resource file
The template its Program.cs comment and its documents say that MP Core's own messages are English and that the repository adds its languages; docs/architecture.md lists the steps
What MP Core does not do the capability catalogue, and the one a generated backend carries (docs/capabilities.md, "Outside this backend"), name four kinds of platform work as not available and name their owner, mp-platform and its base repositories: configuring an identity provider (realms, clients, flows, read-back), configuring a gateway (routes, plugins, path rules), an extension of the identity provider (a Keycloak SPI scaffold), and platform jobs (a monitor, a telemetry collector, alert rules, secret custody). The owner's decision of 2026-10-09; no code changes

How to carry a person's token as evidence:

// The calling service
builder.Services.AddMPCoreResilientHttpClient("payments", client => client.BaseAddress = paymentsAddress)
    .AddMPCoreServiceIdentity(options => { /* the service's own client credentials */ })
    .AddMPCoreSubjectEvidence();

// The called service
builder.Services.AddMPCoreSubjectEvidenceValidation(options =>
{
    options.TrustedServiceClients.Add("orders");
    options.AllowedAuthorizedParties.Add("web-app");
    options.MaximumAuthenticationAge = TimeSpan.FromMinutes(10);
});
app.UseAuthentication();
app.UseMPCoreSubjectEvidence();
app.UseAuthorization();
// A command that needs the person:
options.AddPolicy("refund", policy => policy.RequireAuthenticatedUser().RequireSubjectEvidence("staff"));

How an existing generated repository adopts the rules: copy tests/<Product>.Tests/QueryRules.cs, and
ModuleRulesTests.cs or the query test of ArchitectureTests.cs, from a backend generated with this template,
and add NetArchTest.Rules to the test project of a modular monolith. It checks the modules listed in HandlerAssemblies, and changes nothing else.
The rules of what a module owns need MP Core 0.10.0 for CrossModuleWriteAttribute. Run them before
changing anything: each failure names a table, a key, an interface or a handler. A table outside its
module's schema moves with a migration (ToTable(name, schema)); a foreign key across schemas is dropped
and the identifier kept; a writing Contracts interface gets [CrossModuleWrite("<reason>")] or becomes a
message.

Known limits

Limit What to do
With mutual TLS on, every TLS listener of the host requires a client certificate keep a listener for callers without one in cleartext behind the platform's network
AddMPCoreMutualTls sets Kestrel's HTTPS defaults a host that calls ConfigureHttpsDefaults itself replaces one or the other; a listener declared in code is declared after AddMPCoreMutualTls
Wildcard DNS names in a workload certificate are not expanded list the exact names
With revocation on (Online or Offline), a host on macOS refuses every client certificate of a private authority .NET on macOS does not download the revocation list of a private authority, so the status of every certificate is unknown and the host fails closed. Run hosts that check revocation on Linux, where a revoked certificate is refused and the others are admitted (run in a container; not run on Windows; ADR-017)
A backend generated from the template, with mutual TLS on, was not run in this change the template's wiring is held by TemplateContractTests; the generated backend's build is part of the release procedure
The masking of names and of message types runs in MP Core's OpenTelemetry processors a generated host has no other provider; a host that adds one, the console provider included, prints a message it is given whole. Wrap secrets in SensitiveValue, which masks itself everywhere
A backend generated before this version still has the default providers add builder.Logging.ClearProviders(); before AddMPCoreFoundation, and EnableConsoleLogExporter = true to its observability options
With the providers cleared, Logging:Console:* settings no longer apply the level of the pipeline is Logging:LogLevel, or Logging:OpenTelemetry:LogLevel for the pipeline alone (checked by running a host with each key)
Message types of a named service are read from the endpoints mapped when the host starts a client of that service, or a type outside the service, is added with AddMPCoreSensitiveMessageTypes(typeof(...)); an endpoint added later by a dynamic data source is not seen
A request object of a named service that is logged before the host starts is printed whole, on the console and in the log export: by a hosted service added with AddHostedService (it starts before the web host's own hosted service, which fills the registry) or by code between Build and Run log it from the application-started callback or later, or register its type in the service collection with AddMPCoreSensitiveMessageTypes(typeof(...)), which holds it from the moment the container creates the registry (seen in one host: a hosted service and a log between Build and StartAsync printed the request whole, and *** with the type registered that way)
An exception message built from a message's text, and log scopes, are not scrubbed build exception messages without message objects
Token introspection authenticates with a client secret in the Authorization header only (client_secret_basic) a provider that requires another client authentication needs its own client
A cached introspection answer can outlive a revocation by up to MaximumCacheDuration (RFC 7662, section 4) leave the cache off for high-risk calls, as by default
Introspection was tested against a fake endpoint, not against a running identity provider
The resource-key check at startup sees the endpoints mapped before the host starts an endpoint added later by a dynamic data source is not checked: without a key it is protected only by its own policies and the authenticated fallback
AllowAnonymous counts as an exemption from resource keys an anonymous endpoint has no actor to decide for
A Contracts method is judged by its name: one that returns a value and starts with a reading verb is taken to read a method that writes is named for what it does; GetOrCreate... passes the rule and is caught only by review
The schema and foreign-key rules read the Entity Framework model of AppDbContext a table or key created by raw SQL in a migration is not seen
Evidence travels one hop a service called with evidence does not carry it further
The person's token must name the called service in its audience configure the audience at the identity provider
A refusal of a call without a client certificate under mutual TLS is not part of evidence mutual TLS between services is a separate change
MP Core's audit trail records the caller, the service record the person from SubjectEvidence in the command's own business audit record
The generated host wires one issuer from Security:Authority; mpcore configure --security-authority sets one value a product with several issuers replaces the AddMPCoreBearerAuthentication call of its Program.cs
A token is routed by the issuer its options name (ValidIssuer, else Authority) when a provider's issuer differs from the address of its metadata, set ValidIssuer
Encrypted tokens (JWE) name no readable issuer they are refused, as before
A prerelease's template and CLI embed the declared version (0.10.0), and the manifest of a backend generated from them names it pin the prerelease with --mpcore-version <version>-main.<n>
A prerelease number is a commit's place in main's history; a history rewritten to fewer commits would number a new commit below an older prerelease nuget.org refuses the same version twice; never rewrite main once a prerelease is published
Nothing is published by itself the owner starts release.yml for each commit he wants as a package
Packages built from a working tree with uncommitted changes record HEAD the release workflow builds a fresh checkout; a local pack is a verification candidate only

Verified

An uncached gate on the final commit of the branch, run on macOS with Apple Silicon and the five integration
services of eng/compose.test.yaml (PostgreSQL, TimescaleDB, Redis, RabbitMQ, Kafka), as the release workflow runs
it. The commit that adds this section changes this document only; the gate was run on it again and printed the same
counts.

Step Result
eng/check-version-moved.sh, and its self-test passes; the self-test passes its 4 cases
Locked restore succeeds
Release build, no incremental, warnings as errors 0 warnings, 0 errors
Test run, 15 assemblies 723 passed, 0 failed, 0 skipped (table below)
Pack 30 packages and 28 symbol packages; API compatibility validated against 0.9.3 for the 28 runtime packages, 0 new in this cohort
eng/verify-release-artifacts.sh 93 checks passed, 0 failed; all 30 packages record the commit under test and every assembly carries it
The same script's self-test all 15 bad artifacts rejected, each for its own reason
eng/verify-consumer-pin.sh 4 of 4 cases: the recorded commit builds, another commit fails and names the package and both commits, a short commit is refused, no pin means no check
A service and a modular monolith generated from the packed template, built against the packed packages in an isolated cache the service passes its 6 tests; the modular monolith passes 2 and reports 8 as skipped, its eight module rules, because a modular monolith as generated has no module to check
Test assembly Passed Failed Skipped
MPCore.Domain.Tests 14 0 0
MPCore.Application.Tests 71 0 0
MPCore.Persistence.Tests 5 0 0
MPCore.Persistence.Timescale.Tests 10 0 0
MPCore.Observability.Tests 22 0 0
MPCore.Caching.Tests 10 0 0
MPCore.Transport.Http.Tests 90 0 0
MPCore.Audit.Tests 22 0 0
MPCore.Validation.Tests 13 0 0
MPCore.Localization.Tests 36 0 0
MPCore.Resilience.Tests 45 0 0
MPCore.Idempotency.Tests 21 0 0
MPCore.Security.Tests 223 0 0
MPCore.Transport.Grpc.Tests 101 0 0
MPCore.Messaging.Tests 40 0 0
Total 723 0 0

Three things that a count does not say. The run needs the five services: without PostgreSQL the tests that use it are
skipped, and without .mpcore-product-terms (CONTRIBUTING.md) one more test, the product-name check, is reported as
skipped with its reason. The generated-backend tests and the packing test are in this run; continuous integration
runs them in a job of their own. A gate on macOS does not exercise how the platform reaches a revocation list: the
revocation test asserts what macOS does and was also run on Linux, in a container (ADR-017).

Four packages, MPCore.Idempotency.EntityFrameworkCore.PostgreSql, MPCore.Localization,
MPCore.Localization.EntityFrameworkCore.PostgreSql and MPCore.Validation.FluentValidation, were published
since 0.9.0 but still listed as new in the cohort (eng/package-ids.sh), so their API was never compared
with a baseline. The list is now empty, every runtime package is validated against 0.9.3, and
eng/restore-api-baseline.sh fails while a package listed as new is published at the baseline version.