Changelog
Every notable change, rendered from CHANGELOG.md.
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[Unreleased]
Section titled “[Unreleased]”-
The standalone image is published to GitHub Container Registry as
ghcr.io/burgyn/alvo, forlinux/amd64andlinux/arm64(.github/workflows/image.yml). Every push tomainpublishes:edgeand:sha-<7>; av*tag publishes:<major>.<minor>.<patch>,:<major>.<minor>and:latest. The image now carries the runnable examples’ descriptors under/alvo/examples/and OCI labels (source, licence, version, revision); it still ships no credential.docker-compose.quickstart.ymlis the no-clone quick start: download that one file, exportALVO_DEMO_KEY_SECRETandALVO_ADMIN_PASSWORD,docker compose -f docker-compose.quickstart.yml up --wait— Alvo over PostgreSQL on127.0.0.1:8080, serving the in-imagevehicle-registryexample, with/scalarand the dashboard at/admin.ALVO_DESCRIPTORswitches to another shipped example or to your own file mounted read-only; the demo key (built-in rolesadmin+authenticatedonly) authenticates against all of them, and the file’s header says what to add for field-service’s tenants or your own roles. Before publishing, the workflow smoke-tests the linux/amd64 build of the same commit through that file (scripts/test-quickstart); the pushed two-arch image is a rebuild from the same cache, and the arm64 variant is not smoke-tested in CI. The startup refusals that suggesteddocker run … mmlib/alvonow name the published image. -
CEL functions in hook conditions and before-hook
mutatevalues (slice C1). A host registers a typed C# function at startup withAddCelFunction(name, delegate, summary)on the Alvo builder. A descriptor’s hook conditions andmutatevalues call it by name, and so can the built-ins. C1 brought five (replace,trim,size,abs,round); slice D, below, widens the set to 19 and renames the last two tomath.absandmath.round(see Changed (breaking)). Apply refuses an unknown name, a wrong argument count or a type mismatch, and Rules and computed fields refuse calls for now. A function that throws, or an argument that does not fit its parameter, rolls the write back with the new problem typefunction-failed(HTTP 500), and the host’s own message is never echoed.GET {m}/projects/{p}/cel/functions(Viewer) andIAlvoManagement.GetCelFunctionsAsynclist every callable function with its signature, summary, provenance and profiles, and the schema assistant gains theget_cel_functionstool. A host function runs inside the write’s transaction with no time budget orCancellationTokenyet (#309). -
A literal
field.defaultis honoured (#113’s literal half). A field declaring"default": false/"normal"/1now emits a columnDEFAULTin the generated DDL on both engines, and any write that composes a whole row — a create, and both branches ofPUT— fills in the value for a field the payload does not carry. Arequiredfield with a default therefore stops being a422, which is the forward commitment PR-I recorded.FieldSchemagainsDefault(JsonElement?, the literal as declared). The examples that had lost theirs have them back:simple-tasks(done,priority) andvehicle-registry(passed).Refused, and named at apply: a
{"$cel": …}default, which needs the caller’s context at write time and stays with #113; a literal the field’s type cannot hold; a literal its own facets exclude (maxLength, an enum’svalues, a malformeduuidor date); and a default on acomputedorrollupfield, whose value is maintained for it.GET {m}/capabilitiesno longer reportsfield.defaultas wholly refused — only its$celhalf. -
Hook functions end to end (slice D). A before-hook
conditionormutatecan now compute:- 19 built-in CEL functions, 32 overloads, in
ConditionandMutate: textlowerAscii upperAscii trim replace substring size contains startsWith endsWith; numbersmath.abs math.round math.ceil math.floor math.greatest math.least(math.round(x, digits)rounds to cents); conversionsstring int timestamp; andnow()(Mutate only).cel.mdlists each one’s exact semantics, and a doc test holds that table to the catalog. Rules and computed fields do not get them until SQL translation (slice C2) lands. - Arithmetic in a hook (
+ - * /, unary-), and string+joins in amutate(upperAscii(new.brand) + ' ' + new.frame_number). Int stays Int and/truncates toward zero, as CEL defines. In those two profiles an overflow, a zero divisor or a present operand nothing can take fails closed (see Changed (breaking)). - The dashboard’s hook editor lists the callable functions. Under a mutate value in expression mode, and under a condition in text mode, Functions you can call here shows every function with its signature, summary and a built-in or this host badge. Insert writes the call into the box.
- Three guided text operators in the condition table: starts with, ends with and contains, written as
startsWith,endsWithandcontains. - Demo hooks in
examples/bike-workshop: a frame number normalised withupperAscii/trim/replace, arack_tagjoined with+, the week discount on a rental rounded withmath.round(x, 2), and the workshop’s own address refused on a customer withendsWith.
- 19 built-in CEL functions, 32 overloads, in
-
A documentation site at https://alvo.burgyn.online/ (
website/, Astro Starlight). Task-phrased guides — start here, data modelling, security, behaviour, API usage, C# extension, operations — sit beside a reference generated at build time bytools/MMLib.Alvo.DocsGen: the descriptor reference fromschema/project.schema.json, the problem types, configuration keys and limits from the code, the Data API, Management API, CEL catalogue and capabilities captured from a real host, the C# API from the XML docs, and every HTTP exchange a guide shows run against a real host rather than written by hand. It also renders the changelog, the contributing guide and the examples page, and publishesllms.txt/llms-full.txt. Snippets are validated against the schema and applied in ring0;.github/workflows/docs.ymlbuilds it on every PR that can change it and deploysmainto GitHub Pages. The first-run pages (Quick start, Run your own descriptor, the Tutorial) and the guides built on them run the publishedghcr.io/burgyn/alvoimage throughdocker-compose.quickstart.yml, with a downloaded compose override and descriptor where a page needs its own keys: no clone. Cloning stays the path for building from source and for the embedded samples. -
ALVO_DESCRIPTORpicks the descriptor the root compose stack runs.ALVO_DESCRIPTOR=./my.alvo.json docker compose up --build --waitmounts that file instead ofexamples/vehicle-registry; unset, nothing changes. A descriptor whose roles the demo key does not hold adds keys in a gitignoreddocker-compose.override.yml, as the docs’ Run your own descriptor shows.scripts/test-e2eignores the variable, from the shell and from a root.env, so the suite always runs the stack it was written for. -
examples/help-desk, a support desk’sticketswith enum priority and status, literal defaults, a computed field,audit, role-differentiated rules and two before-hooks. It applies as it stands, and it is the end state of the docs site’s tutorial and the source of the README’s “See it” section.
Changed (breaking)
Section titled “Changed (breaking)”-
IAlvoManagementgainsGetCelFunctionsAsync(slice C1). A caller is unaffected. An implementer or decorator of the interface must add the member, for example by delegating to the inner instance. -
MapAlvoDataApi()now returnsIEndpointConventionBuilderinstead ofIEndpointRouteBuilder(#182), so a host can attachRequireRateLimiting, an authorization policy, output caching or a telemetry tag to Alvo’s generated routes and to nothing else — which is what every other ASP.NET CoreMap*over a set of endpoints returns. A caller that discarded the result (every in-repo one, and the shape the docs show) is unaffected; one that chained a secondMap*off it, or stored it in anIEndpointRouteBuilder, is a source and binary break. Two things about the seam are contract rather than implementation: conventions must be attached before the first request materialises the route table, and one attached after throws — a deliberate deviation from the framework, which ignores late conventions, because Alvo’s table is frozen once built and a silently droppedRequireRateLimitingis a rate limiter a host believes it has.MapAlvo()still returns the route builder andMapAlvoHealth()is deliberately not chainable: one builder over the probes and the Data API would let an authorization policy reach/health/live, which is a container restart-looped by its own liveness gate. -
AlvoQuery.EnsureSortKeysCanBePagedis removed (#116). It refused a paged read sorted by a nullable field, because a keyset boundary could not express where nulls sort. That boundary now can, so the guard has nothing left to refuse — and keeping it as a no-op would leave a member everyIAlvoDataimplementation goes on calling forever. Delete the call; nothing replaces it. The API-layer refusal it produced, theunpageable-sort-keyviolation code, is gone with it: a request that used to earn it is now answered. -
The list response envelope gained a third member,
count(#110). It is always present and isnullunless the request sent a recognisedPrefer: countpreference, exactly asnextis always present and null on the last page — the envelope’s members are a statement about the bytes. A client that rejects unknown members, or that pins the published schema’srequiredlist, sees the change. -
A dev API key’s
Secretmust now be at least 32 characters (#125).AlvoAuthOptionsValidatorrequired only that it be non-empty, soSecret = "password"was accepted — andApiKeyHashis a single unsalted SHA-256 pass, which is only as strong as the assumption that the secret is random. A host configured with a short dev secret now fails at startup, naming the key and both lengths, rather than starting silently weak. Generate the secret rather than choosing it —openssl rand -hex 16, the recipe this repository already publishes inscripts/test-e2e,playground/runand the examples’ READMEs, is 128 bits written as exactly 32 characters, which is why the floor is set at that length rather than above it. Length is a proxy for entropy and not a measure of it — which is why this remains a dev mechanism, and why the real issuance path (#36) must not inherit this hash. -
An entity may no longer be named after one of Alvo’s own tables (#156).
alvo_descriptor_versions,alvo_idempotencyandalvo_outbox— or the same three under a non-defaultAlvoOptions.SchemaPrefix— were excluded from introspection but not reserved, so a descriptor could declare an entity that mapped straight onto one and the framework and the entity would share a table. Such a descriptor is now refused at apply, with the entity’s JSON pointer and a fix. The names come from one internal authority both the provider and the core read; no public surface changed. -
A field declared both
requiredand unconditionallyreadOnlyis now refused at apply (#124). The combination made every create of its entity unsatisfiable — supplying the field was refused as read-only, omitting it as missing — while the published OpenAPI document described a create the API would not accept. An expression-valuedreadOnlyis unaffected: it is legal for one role and impossible for another, and the request-time half below answers that caller. -
A before-hook
mutatevalue must honour its target field’s facets (#308, Ruling V). Two changes for descriptors already stored. At apply: amutateliteral outside the field’smaxLength, enumvalues,formator decimal precision/scale — or anullinto arequiredfield — is now refused, so a host booting from a stored descriptor that carries one fails the boot-time apply instead of starting. Before, the same literal was stored silently on SQLite and failed every firing as an anonymous500on PostgreSQL, so the descriptor never worked on both engines; refusing it at boot, before 1.0, trades a start that used to succeed for one answer on every engine, named at the hook’s pointer. At write time: a computed value outside the facets refuses the write as the hook’s403 forbidden(a per-row refusal in a batch) where SQLite used to store it with a201. The check runs once on the final patch, so a later hook may still repair an earlier one’s value; the refusal names the field and facet only when the descriptor does not flag the fieldhidden. -
Hook arithmetic, comparisons and Boolean positions fail closed (slice D, Rulings P–R and Y-D). In a before-hook
conditionormutatevalue — and only there — an Int or Decimal overflow, a zero divisor, or a present operand an operator cannot take (a value of an unexpected CLR type, a non-Bool where a Bool is needed, two such values underchanged(f)) used to answernullorfalse, and in arejectcondition thatfalsemeant the reject silently never fired. It now refuses the write: HTTP 500function-failedwith nothing written, as a failing function does (an in-processIAlvoDatacaller receives an exception; an after-hook condition drops its hook with a Warning). An exception nothing anticipated inside the interpreter fails the same way, under a constant detail, with the original logged at Error. A null operand still answers as before. A literal zero divisor (x / 0) in a hook is refused at apply. Rules,accessand computed fields do not change. Only an embedded caller’s own records can reach the operand cases; the HTTP binder types every value. -
absandroundare nowmath.absandmath.round(slice D). Both names came in with slice C1, which has not shipped: no released version had them, so this is a break only relative to C1, and C1 and D must ship in the same release. -
IPredicateRenderer.Renderrefuses aCondition-profile expression (R-16).SqlPredicateRenderernow throwsNotSupportedExceptionfor one. It used to render it in part, but a hook condition’s functions and fail-closed operators have no SQL backend. No product caller renders aCondition, but a host that called the public renderer with one will see the throw. -
lowerAsciiis an ordinary built-in, widened rather than narrowed. It now takes any text expression (lowerAscii(trim(new.email))) and is legal in acondition. Every source that compiled before compiles and evaluates to the same value. Only the refusal messages of a few computed-field sources changed wording. -
examples/bike-workshop:rack_tagisreadOnly. A before-hook writes it, so a client that sends it now gets422 read-only-field. The seed carries norack_tagand is unaffected. -
Alvo applies the descriptor on boot by default, and the host no longer applies anything itself. The boot sequence runs as part of the host lifecycle, before the server binds: it loads and validates the descriptor, brings the schema up as far as the startup mode allows, primes the policy catalog and publishes a boot state a readiness probe reads. Three consequences a consumer can see:
Alvo:Schema:Startup(Alvo__Schema__Startup) defaults toApply. On drift — a descriptor that no longer matches the schema recorded for the database — the boot applies the difference instead of refusing. Initialization of a database Alvo has recorded nothing for was never governed by the mode and still is not, in every mode butSkip. The destructive gate is separate and always on, so no mode drops or narrows anything withoutAlvo__Schema__AllowDestructive=true. The cost is real and is the reason a production deployment setsVerify: every replica of a rolling deploy attempts the DDL, the application needs DDL rights against its own database, and — the sharp one — an additive deploy underApplymakes the rollback destructive, so redeploying the previous descriptor refuses and every pod crash-loops until someone setsAllowDestructiveor applies the older descriptor from a migration job.Skiprefuses to start in exactly one state: Alvo has recorded nothing and the live schema does not match, i.e. nothing has verified the schema exists.docs/architecture/host.mddocuments the posture.AlvoHost.BuildAsyncno longer takes aCancellationToken. There is nothing left in it to cancel — the apply it used to perform is the host lifecycle’s now, cancelled by the tokenStartAsyncalready carries. A caller passing one no longer compiles; dropping the argument is the whole migration.IRuntimeSchemaWriteris now mandatory for a database provider. It used to be resolved on demand by the runtime apply path only, so a provider could shipIAppliedSchemaStoreplusISchemaMigratorand boot without it. The boot writes every project-schema change through it, because that port inserts the version row first as the optimistic-lock gate and runs the DDL in the same transaction — which is what makes several replicas cold-starting against one empty database converge instead of crash-looping. Both in-repo drivers implement it; a third-party provider that does not can no longer boot.docs/architecture/package-boundary.mdrecords the widened contract, including that anIAppliedSchemaStoremust bring its own storage up idempotently and race-safely on first call.
-
A
uniquefield on atenancy: "scoped"entity is now unique within the tenant, not across the instance (#137). It was a cross-tenant existence oracle:DescriptorModelBuilderemittedHasIndex(field).IsUnique()with notenant_id— and the same for a declareduniqueindex — so tenant B’s create of a value only tenant A held was refused while the same create of a free value succeeded. The two requests differ in exactly one thing, so the difference between the answers was the disclosure: B learned whether A held the value, one request per candidate. That is the inference the 404-everywhere rule exists to prevent, and it contradicted §0’s secure-by-default. A scoped entity’s unique index now spans(tenant_id, …); a non-scoped entity keeps instance-wide uniqueness, a non-unique index is unchanged, and a descriptor that already namedtenant_idkeeps its own column order. Mapping the underlying refusal to a clean 409 did not fix this —409-versus-201is the same one-bit signal as500-versus-201was — which is why the two were separate issues.This changes emitted DDL.
IX_<table>_<field>becomesIX_<table>_tenant_id_<field>on a scoped entity, so the next apply drops one index and creates another (DropIndex/AddIndex, neither destructive, so noAllowDestructiveis needed). The change is always in the widening direction — the new index forbids strictly less than the old one — so no existing row can violate it and no migration can fail on data. Nothing is released, which is why this was the cheap moment. -
IAlvoSqlDialectgained an abstract member,DecodeConstraintViolation. A driver outside this repo will no longer compile until it implements it. Abstract rather than a default interface member on purpose:nullmeans “not a constraint violation”, which is a legitimate answer for every other failure, so an inherited default would have a new driver silently answer500for every duplicate — indistinguishable from correct behaviour on an engine that really reported something else. -
A descriptor may no longer name a field
order,limit,offset,after,select,or,andornot. The generated Data API’s query string reserves each of these (?limit=10,?or=(...),?not.color=eq.red), so a request could not tell a filter on such a field from the parameter itself. The descriptor is now rejected when it is applied, with an error naming the entity, the field, the full reserved list andRename the field; previously such a descriptor applied and then failed when routes were mapped — or, for an embedded host that never maps the Data API, was never refused at all.orderin particular is a plausible business field name (anordersentity with anordercolumn is not exotic), so this will hit real descriptors. Rename the field; there is no opt-out, because the ambiguity has no correct per-request resolution.schema/project.schema.jsondocuments the exclusion on thefieldsdescription — the JSON Schema pattern cannot express it, so it is stated there rather than validated. -
A descriptor is now rejected at apply when it declares a feature this build does not honour, rather than applying and silently dropping it. The rule: refuse what silently produces wrong data; tolerate what an author can observe the absence of. Each refusal names the entity, the field, the consequence and a fix.
field.computed— the expression is never evaluated, so the column stays null.field.rollup— nothing maintains the aggregate, so it reads as permanently null while looking like data.field.validation— the expression is not evaluated, so a value it forbids is accepted and the field is not constrained at all.field.default— no column default is emitted and the value is dropped before any writer sees it, so the field is simply null. On arequiredfield that is an INSERT of NULL into a NOT NULL column. This one has an immediate ergonomic cost and is the first thing to restore (#113).entity.softDelete— a delete would remove the row outright and reads would not exclude it: irrecoverable data loss where the schema promises recoverability.- Each of the six
entity.hooks.*points, refused individually so that implementing one lifts only its own refusal (#114) — abefore*hook may reject or mutate inside the write transaction, so a write the author believes is vetted is neither; anafter*effect simply never happens.
Blocks that are warned about instead of refused, because their absence is observable:
dynamicEntities,automation,templates,webhooks,functions. Applying a descriptor that declares any of them logs one warning naming each. -
A descriptor may no longer declare a field named after a framework-managed column —
id,tenant_id,created_at,created_by,updated_at,updated_by,deleted_at— on an entity whose traits carry it. The refusal is trait-scoped, so an entity that does not declareauditmay still have its owncreated_at. Previously a declaration won, and two defects came out of that: an audited entity declaringupdated_atas{"type":"string"}applied cleanly and then failed every create with an internal parameter name in the response body; and one declaringupdated_atashiddenapplied cleanly and switched optimistic concurrency off in silence, because the mask drops the key from every returned record so noETagis ever minted. This breaks a descriptor that declaresupdated_at, and it also removes one capability:readOnlyontenant_idas a narrowing is now forbidden along with the declaration. Express that intent as acreaterule instead — the synthesized tenant scope’sWITH CHECKis already evaluated over the candidate row, so a rule can answer “which tenant may this row be placed in” per caller and a field flag cannot. -
MMLib.Alvois now an ASP.NET Core library. §0 principle 8 makes every generated endpoint a minimal-API delegate, so the core carriesFrameworkReference Microsoft.AspNetCore.AppplusMicrosoft.AspNetCore.OpenApi. An embedded consumer of the core is therefore an ASP.NET consumer whether or not it maps the Data API — that is the most consumer-visible change in this release for an embedded host.MMLib.Alvo.Abstractionsdeliberately stays free of both, and an architecture test holds that line, so the ports remain implementable by a host that is not an ASP.NET application at all. Side effect: the framework reference suppliesMicrosoft.Extensions.Options, whose explicitPackageReferences had to be removed, because NuGet’sNU1510(an error in this repo) objects to a reference it will not prune. -
AddAlvo()now callsAddLogging(). The core writes at least one warning of its own, so it resolvesILogger<T>and must not require the host to have arranged that. It is idempotent (TryAddthroughout), so an ASP.NET host or one that already called it is unaffected; a plain console host embedding Alvo would otherwise fail to activate the migration runner at all. Note that with no logging provider configured the warning is dropped silently — a startup crash traded for a silent drop.
Changed
Section titled “Changed”-
The README is rewritten, and NuGet packages carry a readme of their own. The repository
README.mdnow leads with what Alvo is, a runnable example and links into the docs site. nuget.org renders no Mermaid,<picture>, raw HTML or relative image, so every package now packsPACKAGE_README.md(PackageReadmeFileinDirectory.Build.props) instead of the repository README. -
Serving the OpenAPI document no longer costs
O(N²)per request (#126). The document is rebuilt on every request to/openapi/v1.json, which needs no credential, and the transformer resolved each entity’s schema and field flags once per entity and again per endpoint — five endpoints per entity, so6Nresolutions. The schema lookup was a linear scan by name, making itO(N²)comparisons, and each flag resolution allocated two fresh sets, so12Nof them. The transformer now reads each source once for the whole document and indexes it. Measured on a three-entity descriptor: schema reads19 → 2, catalog reads18 → 1, andOpenApiDocumentCostTestspins both. The schema lands at two rather than one because serving the document also reads it once throughEntityRouteCatalogwhen ApiExplorer enumerates the route table — a different concern, and one read regardless of entity count. The document itself is byte-identical — no baseline moved — so this is a change in cost, not in contract. -
A create whose caller cannot satisfy it now answers
read-only-required-field, notrequired(#124). When a field isrequiredand this caller’s own expression-valuedreadOnlymask froze it, telling them to supply it sends them to fix something no value of theirs can be stored in. The new violation says the create is impossible for these roles and names the two ways out. A caller who writes the frozen field still getsread-only-field— the new code narrows the missing-value case only. -
maxLengthis counted in Unicode code points, not UTF-16 code units (#123). Ten astral-plane characters are twenty UTF-16 units, so a value well inside avarchar(10)was refused with a 422 telling the caller to shorten something already short enough. Code points is the unit PostgreSQL’svarchar(n)and JSON Schema’s ownmaxLengthkeyword both use, so the validator, the column and the published document now bound the same thing on both shipped drivers. Grapheme clusters were rejected as the unit: they count fewer than the column does, which would have admitted values the engine refuses. The agreement is a two-engine guarantee and is recorded as one — T-SQL’snvarchar(n)bounds UTF-16 units, so a SQL Server dialect owes its own answer before it can honour this (#175). -
A format check that times out is now its own violation code,
format-not-evaluated, and no longer reported asformat. A client branching on theformatcode will no longer see the pattern-timeout case. This is a fix for a fail-wrong, not a cosmetic split: the old behaviour told a caller their value did not match a pattern that had in fact never finished being evaluated, and it was reachable on perfectly valid input — a validemailaddress was refused as malformed once in nine full suite runs, purely because a loaded machine lost the match timeout to scheduling. “I could not decide” and “your value is wrong” are different things to tell a caller, and only one of them is about the value. Both still refuse the request, because an unevaluable check must fail closed; the difference is that the new code’s fix suggestion is retry the request, which is the one action that can succeed when nothing about the value was wrong. -
A 201’s
Locationheader now honoursHttpRequest.PathBase(#121). A host mounted under a path base —UsePathBase("/alvo"), or a reverse proxy sendingX-Forwarded-Prefix— used to advertise/api/owners/<id>, which 404s at the proxy edge; it now advertises/alvo/api/owners/<id>. This is a behaviour change for anyone already deploying under a path base, and the direction is that URLs which used to be wrong are now right: following the header works where it previously did not. No released version is affected — both the header and the fix land in this same unreleased cycle. A host with no path base is unaffected, byte for byte. The OpenAPI document names the origin its paths are resolved against, path base included — see #130 under Fixed — while the Scalar UI’s own behaviour there is still unmeasured (#134).
-
?select=now narrows the read, and gained aliases (#117, #111).AlvoQuery.Selectis a new additive member on the port, honoured by both shipped drivers and the in-memory reference; the guardAlvoQuery.EnsureProjectionIsSanerefuses an empty projection the way the existing paging guard refusesafter+offset. No existing request changes its answer: a caller who sends noselectgets the statement and the body they got before, and?select=makereturns exactly the keys it returned before — what changed is that the database stops reading the columns it did not name. Four things are worth knowing rather than rediscovering:- The
SELECTlist does not get shorter. Reads run throughFromSqlRawover a property-bag entity mapping every schema field, and EF fails when a mapped column is missing from the result set, so an unselected column is renderedNULL AS <col>and its key is dropped when the record is assembled — the mechanismhiddenalready used. The engine stops reading the column, which is a real win for a wide or TOASTed value and near zero for a narrow int. It is not a proportional speed-up and nothing here claims one. - Two groups of columns are read whatever the projection names, and neither is shown unless
named: the framework-managed ones (
IAlvoData’s returned-key-set contract is amended to say so, and the keyset cursor is minted from the row key), and every field named inorder. The second is measured rather than cautious — on SQLite 3 and PostgreSQL 16, a bare identifier inORDER BYresolves against the output column names, so a NULLed sort key would have ordered the page by theNULLwhile the keyset boundary still described the real sequence: a page that skips or repeats a row rather than one that merely mis-sorts. - Aliases are
select=label:make, PostgREST’s own spelling, and never reach the port — the port is given source names, and the response’s key list is rendered above it. New refusals, all onselect:malformed-select-alias(an alias must match the field-name grammar^[a-z][a-z0-9_]{0,62}$and must not be a reserved name — a deliberate narrowing of PostgREST, which admits any alias),colliding-projection-key(a key claimed twice, whether by two sources or by an alias onto any framework-owned name —AlvoManagedColumns.Allis new and answers that question, because a global entity has notenant_idand a response key calledtenant_idwould still read as one), andprojection-too-wide. An alias onto another declared field’s name is deliberately allowed, wrong type and all: PostgREST behaves the same way, the caller chose both halves, and refusing it would defeat renaming. projection-too-wideis a new bound aliases made necessary. A projection may name at most as many distinct keys as there are fields this caller can read, because an alias can otherwise name one column under arbitrarily many keys with only the URL length in the way. It is charged per newly claimed distinct key, so a repeated entry still dedupes exactly as it did. The bound counts the caller’s readable fields rather than the entity’s declared ones on purpose: the number appears in the refusal’s fix suggestion, and the declared count would have told the caller how many fields are hidden from them.
Internal:
DataApiPage.Projectis gone, replaced byRender, which renames and orders rather than filtering — the filtering moved into the port. - The
-
A load-test harness, with a per-PR regression gate and a per-release calibration run. No public API changes and no product code:
test/load/(k6 scenarios, the bulk seed, the gate’s baseline),scripts/test-load,scripts/assert-load-baselineplus its own suite, and.github/workflows/load.yml. This is what fills F4’s “p95 latencies measured and published” and it puts numbers on six filed-but-unquantified costs (#100, #117, #118, #126, #178, #179); the published figures live indocs/performance.md. Three decisions worth knowing rather than discovering:- k6, and NBomber is refused. NBomber v5+ is closed source and needs a paid licence for any
organisational use. k6’s AGPL-3.0 places no obligation on Alvo because it is invoked as a
separate process and never shipped — the general rule (a ban reaches a shipped dependency, not
a CI tool) is now recorded in the
alvo-dotnet-conventionsskill. - The gate is judged on
min, not p95, and this was measured. At gate volume every p95 landed within 8-9 ms of every other whileminseparated the shapes cleanly, so gating on p95 would gate on the runner. p95 is still measured, printed and published; the tail-only regression thatmincannot see is named in the guard’s own header and pinned by its suite. test/load/baselines/*.jsonis a judged baseline, like a*.verified.*snapshot: raising a ceiling is the one edit that turns the gate green with no product change, so the Stop hook dispatchesalvo-snapshot-judgewhen it moves.- A ceiling is only valid for the tier it was measured on, and this was measured too. The
ratios grow with row count —
Prefer: count=exactcosts 1.6x the reference list at 20 000 rows and 3.0x at 200 000 — because the fixed per-request overhead stops dominating as the database’s share grows. So the calibration tier reports rather than judges (--report-only); its validity checks still bite, because a void run publishes garbage.
The gate ships advisory, not as a required check; promoting it wants a couple of weeks of real PRs with no false positive.
Three reviews ran before the PR and found real defects rather than nits — most usefully that the
row_policyratio (the rule engine’s hot-path number) was unfalsifiable: a ratio can only reward a cheaper policy path, and the cheapest row predicate is one that matches nothing, so a default-deny bug would have published an improvement. The harness now asserts the row predicate still returns a strict subset before k6 starts. Three fail-open paths in the guard were also closed — a misspelled baseline key judged nothing and printedok, an unmeasured row could not fail, and a zerominread as the fastest thing in the run — and its suite grew 22 → 40 cases.The gate’s own first CI run then corrected the design twice. p95 does not degenerate on
ubuntu-latest— its ratios trackminwithin ~10 % there, so the collapse the design described is a property of macOS + Docker Desktop, not of the gate tier; the claim is now scoped to its rig, and the case forminis the better one (it means the same thing on both rigs, where p95 collapses on one). And the runner’s ratios run 15–30 % higher than a laptop’s, which leftcount_exactwith 18 % margin under a ceiling set from laptop numbers alone — raised to 3.0 from the runner’s own numbers, withobservedandobservedOnTheRunnerkept separate so the distinction cannot be lost. A ceiling is measured on the rig that judges. - k6, and NBomber is refused. NBomber v5+ is closed source and needs a paid licence for any
organisational use. k6’s AGPL-3.0 places no obligation on Alvo because it is invoked as a
separate process and never shipped — the general rule (a ban reaches a shipped dependency, not
a CI tool) is now recorded in the
-
/health/readynow reports whether the database can still be reached (#133), so a store that goes away after boot drains the pod’s traffic instead of being invisible. This changes what an orchestrator does with a running host: readiness answered 200 for the life of the process once the boot had primed the schema, and it can now answer 503 while the process keeps running and/health/livekeeps answering 200 — which is the point, and which a deployment whose readiness probe gates traffic will notice. Liveness is unchanged and still evaluates no check at all.- No new public API. The core opens no connection of its own — the probe is a port,
IAlvoDataReachability— but that port and its answer areinternaltoMMLib.Alvo.Abstractions, reached by the four in-family assemblies throughInternalsVisibleTo. Nothing about it is a contract you can depend on or need to implement: the shared EF path implements it once, so every EF-backed driver inherits a working probe, and the statement it runs is aconstin that implementation rather than a member onIAlvoSqlDialect.publicis one word away on the day a non-EF driver or a host substituting the probe needs it; un-publishing an interface is the breaking direction, so the asymmetry decides it. - A driver with nothing cheap to ask opts out by not registering the port, and readiness is then exactly what it was before. That is fail-open on purpose: readiness is an availability gate, not an authorization one.
- The probe is bounded by
HealthCheckRegistration.Timeout(two seconds). It is a cooperative bound — the framework cancels the token and awaits the check — so a probe that honours its token becomes a 503 and one that ignores it holds the request; honouring it is the port’s documented obligation. - It costs a database round trip per request, on a route that carries no credential. Readiness was a pure in-memory read before; a caller who can reach the port now makes the process spend a connection from the pool the Data API shares, at their chosen rate, and a saturated pool times the probe out and has the pod drained. The assumed caller is a private orchestrator polling at an interval — which is what every readiness probe assumes and nothing here enforces. Bounded, disposed per probe, and tracked as #183, where caching the answer for a short window is the likely resolution.
- Cache and message-bus reachability remain owed; the readiness tag is what makes each additive.
- No new public API. The core opens no connection of its own — the probe is a port,
-
?order=<nullable field>works, andnullsfirst/nullslastfinally do something (#116). Every list over HTTP is paged, and a paged read sorted by a nullable field used to be refused with 422 — so sorting by adisplay_namethat may be null was impossible, and half the published sort grammar could not be reached. The keyset boundary now compares the same (where the null sorts, then the value) pair theORDER BYranks by, so a nullable key pages like any other and a cursor walks the null-keyed rows too.nullslastis the default when a key does not say otherwise; where a null sorts is never left to the database, because SQLite and PostgreSQL disagree on it. The cost is real and worth knowing: the null placement is emitted as aCASEexpression over the key, which an index on that key cannot serve, so page by a required column where latency matters. Per-dialect nativeNULLS FIRST/NULLS LASTis the follow-up (#178). -
Prefer: count=exactfills the page envelope’scount(#110), with the number of rows the query matches in total — narrowed by your policy and your filter, and not bylimit,offsetorafter, so it does not shrink as you page. Opt-in, because an exact count is a second scan of the matching set on every request; a request that sends no preference costs exactly what it did before.count=plannedandcount=estimatedare accepted and degrade to an exact count — a planner estimate exists on one supported engine and not the other, and this API answers identically on both — andPreference-Applied: count=exact(RFC 7240 §3) tells the caller what was done. Per RFC 7240 a preference this server does not recognise is ignored rather than refused; its absence fromPreference-Appliedis how that is reported. Exact means “not an estimate”, not “atomically consistent withitems”: the count is a second statement, so a write landing between the two can make the number differ by one. -
A standalone host you can run without writing any code.
docker compose upbrings up a working backend defined entirely by a JSON descriptor mounted at/alvo/descriptor.json— no project, no migrations, no scaffolding. What you get:- The descriptor is the whole backend. Entities, fields, validation and per-operation rules
from the mounted file become tables and a REST API. Edit the file and restart, and an
additive change (a new entity, a new field) migrates on the way up. A destructive one
does not: a plan that would drop a column or a table is refused in every startup mode unless
Alvo__Schema__AllowDestructive=trueis set, so the container fails to start rather than losing data on a restart. Note what that costs on the way back: rolling the descriptor back after an additive change plans a drop, which is refused, so a rollback needs either that setting or a migration job — seedocs/architecture/host.md. An entity the file does not declare 404s, which is the point: nothing is baked in. - Interactive documentation at
/scalar, rendering the OpenAPI document the host serves at/openapi/v1.json. It works with no outbound network access — the assets ship inside the image.Alvo__Docs__Enabled=falseremoves both routes. - Two probes, configured oppositely.
/health/liveevaluates no health check at all, so nothing anyone registers can make it fail and get the container killed; it means only “the process is up”./health/readyis the schema signal: 503 until Alvo’s boot has applied the descriptor and primed the policy catalog, 200 after, with the boot phase as the whole body and nothing else in it — the reason a boot refused can carry a path or a connection string, and a probe is unauthenticated by design. A host whose boot refuses never listens at all and exits non-zero. The stack’shealthcheckand both compose files probe readiness. What is still missing is the continuing database-reachability half, which needs a port (#133). - Configuration is standard .NET environment binding —
Alvo__DescriptorPath,Alvo__Database__Provider(sqlite|postgresql),ConnectionStrings__Alvo,Alvo__PathBase,Alvo__Docs__Enabled,Alvo__Auth__DevKeys__0__*, plusAlvo__Schema__Startup(Verify|Apply|Skip, defaultApply) andAlvo__Schema__AllowDestructive. SQLite is the zero-configuration default; an unknown provider name is refused rather than defaulted, an unknown startup mode is refused naming all three, and a PostgreSQL host with no connection string fails rather than quietly writing to a container-local file. Every refusal names the environment spelling an operator can type and what to set. - Behind a reverse proxy,
Alvo__PathBaseand — opt-in, off by default —Alvo__ForwardedHeaders__EnabledforX-Forwarded-*. Off by default deliberately:X-Forwarded-Prefixdecides the URL a 201 advertises, so an untrusted caller honoured by default would choose where the next client is sent. - No default credential, ever. The image ships no API key and seeds none; the demo stack
refuses to start until you supply one. A host with no key configured still starts and still
refuses every write. The stack publishes its port on
127.0.0.1only, so following the quickstart on a cloud VM does not put a read/write backend on the internet — Docker’sDOCKER-USERchain sits ahead of a host firewall, so a0.0.0.0bind here would not be stopped by one. - An end-to-end suite (
scripts/test-e2e) that builds the image, brings the stack up against PostgreSQL, runs TeaPie against the published port and asserts the created row is in the database. It runs in CI on every pull request.
- The descriptor is the whole backend. Entities, fields, validation and per-operation rules
from the mounted file become tables and a REST API. Edit the file and restart, and an
additive change (a new entity, a new field) migrates on the way up. A destructive one
does not: a plan that would drop a column or a table is refused in every startup mode unless
-
A runnable complex demo, and an end-to-end suite that measures what F3 claims.
examples/field-serviceis a multi-tenant field-service backend — global reference data beside two tenant-scoped entities, one audited and one not, an optional hidden field, a required hidden field, areadOnlyfield, an unconfigured operation, both a caller-level and a row-level rule, every field type, both kinds offormat, and indexes over the fields the tests filter and order on. Its README states, per construct, which behaviour it exists to let a test measure.docker-compose.field-service.ymlruns it on:8081with five dev keys differing only in role and tenant; the repo-rootdocker compose upis unchanged.test/teapie-field-servicedrives 327 assertions against that container: the PostgREST query grammar including keyset paging over four pages, RFC 9457 problem documents carrying every violation at once,ETag/If-Matchin both directions of theauditpair,Idempotency-Keymeasured by row count, field confidentiality compared as a whole refusal document, all three authorization shapes in one system state, tenant isolation, the published OpenAPI document, and six multi-step CRUD journeys that end by asserting the state of the world.scripts/test-e2eruns both stacks and adds two PostgreSQL assertions no HTTP check can make — that a hidden field’s value really is stored, and that the two tenants’ rows really are partitioned.Three defects it found are recorded rather than fixed here, each pinned by a labelled case that turns red when the defect is. They are two independent problems, and conflating them would let a fix for one be mistaken for a fix for the other:
- A database constraint violation is answered
500, not409. Two reachable shapes — a duplicate value on auniquefield, and a delete blocked by anonDelete: restrictreference. Every other declared facet is validated and answered with a per-field 422; a database constraint is not mapped ontoIAlvoData’s refusal families at all, so an agent gets no violation, no pointer and no field name. Pinned by030-Problems/002and100-Scenarios/001. - A
uniquefield on atenancy: "scoped"entity is unique across all tenants. The driver emitsHasIndex(field).IsUnique()with notenant_id, so tenant B’s create collides with a value only tenant A holds — a cross-tenant existence oracle, one request per candidate, and the one channel through which the isolation the rest of the framework enforces leaks. Mapping the violation to a clean409does not close this:409-versus-201is the same signal to tenant B as500-versus-201. The fix is a tenant-scoped unique index. Pinned separately by080-Tenancy/002, which asserts distinguishability rather than a status precisely so a status-only change cannot be mistaken for a fix.
Known limits, so this is honest: the image is not published yet — you build it from this repository — and there is no dashboard, no Management API and no CLI (#24, all F4). A mis-typed descriptor mount currently ends in a stack trace rather than a readable refusal (#132), and the docs UI’s behaviour behind a path base is unmeasured (#134).
docs/architecture/host.mdrecords what the host is and what it deliberately is not. - A database constraint violation is answered
-
MapAlvo()andMapAlvoHealth(), plus a boot state to read — new public API in the core.MapAlvo()maps everything Alvo serves (the Data API and both probes) in one call, andMapAlvoHealth()maps the probes alone. Neither needs the schema to exist yet, and neither doesMapAlvoDataApi()any more: route literals are read when the endpoint table is first enumerated, on the first request, so the old ordering rule “apply before you map” is gone —register → map → boot → listenis the sequence, and the boot runs before the server binds.AlvoBootState(withAlvoBootPhase) is what the boot publishes for a readiness probe, a CLI or a dashboard to read: the phase, and the applied revision it primed from. -
IServiceProvider.ApplyAlvoDescriptorAsync()— new public API in the core. The one verb a host performs on a built container: bring the configured descriptor up, creating or migrating the schema it declares. It is no longer the startup path — Alvo’s own boot does that before the server binds, and it is also what primes the policy catalog (an unprimed catalog denies everything) — so this is the explicit runtime apply: a CLI, a migration job, a dashboard. The ordering rule it used to carry (“call it before mapping endpoints”) no longer applies. Previously the orchestrator behind it wasinternal, so only code inside the core assembly could apply a descriptor at all. A refusal is a return value, not an exception — a caller doing a dry run wants to read the plan — so a host that wants a running backend callsMigrationResult.EnsureApplied()on the result, also new. It throws only on a plan that was neither applied, empty, nor a dry run, naming the destructive steps that were refused; an unchanged descriptor (empty plan) and a dry run pass through untouched. -
AddAlvoProblemDetails()— new public API in the core, and opt-in:AddAlvo()does not register it, so nothing changes for an existing host. Registering it, together withUseExceptionHandler(), makes an unhandled exception on one of Alvo’s generated routes come back as Alvo’s own problem document (type: https://alvo.dev/errors/internal) with a constant detail and nothing about the exception in the body, logged with its stack trace server-side — except when the caller simply hung up (anOperationCanceledExceptionon an aborted request), which is not an error and is not logged as one. It is opt-in because an embedded host owns its own error rendering, and Alvo silently swallowing your exceptions would be the wrong default. It also callsAddProblemDetails()for you, becauseUseExceptionHandler()refuses to configure itself with neither a handler path nor a problem-details fallback. Theinternalslug joinsAlvoProblemTypes.Alland the publishedproblemDetailsschema’stypeenum.The handler declines what is not Alvo’s, which is what makes it safe to add to a host that renders its own errors: an
IExceptionHandleryou register after it still runs — for your own endpoints, and for anything that failed before routing matched. And a request your web server refused before Alvo could read it (a body over Kestrel’sMaxRequestBodySize, an upload the client truncated, a body arriving too slowly) is answered at that status — 413, 400 or 408 — under the newhttps://alvo.dev/errors/unreadable-requestslug, and logged atWarningwithout a stack trace, rather than coming back as a 500 that tells an agent to retry a request whose size is the thing that has to change. -
The HTTP Data API. A host that calls
MapAlvoDataApi()gets a REST API generated from its descriptor: five routes per declared entity (GETcollection,GET {id},POST,PATCH,DELETE {id}) under a configurable prefix, each one a minimal-API delegate gated by the entity’s own rules. What comes with them:- A PostgREST-shaped query string, adopted rather than invented so an agent recognises it:
ten operators (
eq neq gt gte lt lte like ilike in is),or=(…)/and=(…)grouping, anot.prefix,order=field.desc.nullslast,select=a,b, and both paging modes — keyset via an opaqueaftercursor, plusoffsetas the opt-in second mode. Page size is server-enforced. - Structured refusals. Every error is an RFC 9457 problem document with an Alvo
typeslug (https://alvo.dev/errors/…) and aviolationsarray carrying a JSON pointer, a machine-readable code, a message and a fix suggestion for every problem with the request — not just the first. - Optimistic concurrency, on an entity that keeps a row version. A single-row read and a write
return a strong
ETagover that version — only where the entity declaresaudit: true, which is what mints the version column; an entity without it gets noETag, and a list never carries one.If-Matchon aPATCH/DELETEis evaluated inside the write transaction against a row-locked pre-image. A precondition this API cannot evaluate is refused rather than ignored, because ignoring one is the lost update the header exists to prevent — and on a version-less entity the generated document does not offerIf-Matchat all, rather than inviting a header whose every value would be 412. Idempotency-Keyon create. A retried create returns the first one’s result and never duplicates a row. The record stores the created row’s id — never a rendered response — so a replay re-reads through the caller’s current policy and can never hand back a representation that policy would no longer produce.Cache-Control: no-storeon every generated response. These are private, per-caller representations; theETagexists for concurrency, not for a shared cache.- An OpenAPI 3.1 document enriched from the applied schema — per-entity request and response schemas, the query parameters with their real enforced bounds, the problem shape, and an API-key security scheme. §0 principle 4: the document is the contract an agent reads.
Known limits, so the list is honest: no bulk operations, no
PUT/upsert, no relation embedding, no aggregations or total count, andIdempotency-Keyis ignored onPATCH/DELETE. Each is filed with its reason.docs/architecture/data-api.mdrecords the decisions and the surprises — in particular that a configured rule which excludes a caller answers 200 with an empty page, not 403, because a rule compiles to a row-level predicate. - A PostgREST-shaped query string, adopted rather than invented so an agent recognises it:
ten operators (
-
Repository and solution skeleton:
MMLib.Alvo.Abstractions(the interface-first root of the dependency graph) and its test project. -
Central Package Management, shared build settings, pinned .NET SDK,
.slnxsolution. -
First architectural guard-rail (NetArchTest): Abstractions depends on no other project in the solution.
-
Apache-2.0 license and minimal pull-request CI (build + test).
-
Contributor onboarding:
CONTRIBUTING.md(build/test, PR process, transparent CLA explanation), Individual and Corporate CLAs (docs/legal/) based on the Project Harmony v1.0 templates that keep contributor copyright while allowing future relicensing, and a Contributor CovenantCODE_OF_CONDUCT.md. -
Central package management finished: shared assembly/NuGet metadata (author, product, license, repo link, tags, icon, readme), warnings-as-errors, deterministic builds, and SourceLink in
Directory.Build.props; rootREADME.mdand package icon (icon.png, generated fromassets/alvo-logo.svg). -
Repo tooling: CodeQL analysis,
Dependabotversion updates (NuGet + GitHub Actions), a Dependency Review check on pull requests (fails on moderate+ severity or non-allow-listed licenses), and a CodeRabbit config (.coderabbit.yaml) tuned to this project’s conventions (Central Package Management, disallowed packages, XML doc and comment-style rules).
-
A runtime apply now changes the Data API’s fields at once, not after a restart (#353). Every generated endpoint used to keep the entity’s fields from the moment its route was built. Rules followed a
PUT …/descriptorstraight away, but the body reader, the validator and the query parser did not:- a field added at runtime was refused with
422 unknown-fielduntil the host restarted; - a removed field was let through here and then refused by the data port with a
403; - a shorter
maxLengthor a newlyrequiredfield was not enforced at all.
Each request now reads the entity’s fields, facets and formats from the revision its policy decision was taken against. If an apply lands between the two reads, the decision is taken again. This holds on SQLite and PostgreSQL. Route literals are still fixed at startup, so an entity added at runtime still has no route until a restart (#103).
- a field added at runtime was refused with
-
A batch
PATCHstampsupdated_atandupdated_by, so its rows get a newETag(#349). The EF driver’s batch update did not apply the audit stamp that the single-row update applies. A batch-updated row kept its oldupdated_atandupdated_by, so itsETagdid not change. A later single-rowPATCHsent with anIf-Matchfrom before the batch then got200instead of412, and silently overwrote the batch’s change (a lost update). Each batch row is now stamped exactly like a single update, on SQLite and PostgreSQL alike. The stamp is applied after the payload guard and before the hooks andWITH CHECK, the same order the single-row path uses. As a result, a hook or an update rule that readsupdated_byorupdated_atnow sees the stamped values on a batch too. The in-memory reference implementation already stamped; batch create was not affected. -
Webhook deliveries no longer log the endpoint’s URL (#347, security).
IHttpClientFactory’s default logging handlers on the named webhook client wrote every delivery’s full request URI, path included, atInformationunderSystem.Net.Http.HttpClient.MMLib.Alvo.Events.Webhook.*, and a webhook URL’s path is often its only credential (a Slack incoming webhook’s is). The library now registers that client withRemoveAllLoggers(), so the fix holds in an embedded host as well as the standalone one, with no logging configuration required; theLogging__LogLevel__System.Net.Http.HttpClient=Warningworkaround is no longer needed. Alvo’s own lines still record each attempt by endpoint name. A host that wants transport logging back adds its own logger to the client afterAddAlvo, and owns its redaction. -
A rollup field is no longer caller-writable (#342). A payload naming a
rollupfield was accepted and stored:PATCH /api/invoices/{id}with{"net_total": 1}answered200, kept the1, and a computed field reading the rollup followed the forged value until a later child write recomputed it. Every write path now refuses it exactly like a write to acomputedfield —403with problem typeforbidden, the detail naming the field — onPOST,PUT(both branches),PATCHand every batch row (codeforbiddenat/rows/{index}), on SQLite and PostgreSQL alike; an explicitnullis refused too. The framework’s own recompute is unaffected. A client that echoed a read row back into a write must now drop its rollup fields, as it already had to for computed ones. -
The dashboard works over PostgreSQL (#339). Every screen showed “Something went wrong”, with EF’s “a second operation was started on this context instance” on the identity store. A Blazor circuit is one DI scope for as long as the tab is open, and its components initialise concurrently — the overview, the pending bar and the project switcher each resolve the signed-in operator at the same time — so the cookie resolver’s membership store shared one
DbContextbetween overlapping queries. SQLite hid it, because its reads complete synchronously and never interleave. The resolver now reads the store from a scope of its own on every call, the rule the guarded user administration and the session revalidation already follow. Caller resolution is otherwise unchanged: still re-read on every call, so a disable, a role revoke or a tenant move still takes effect on the operator’s next click. -
A
datefield now compares in memory (#317). The CEL interpreter did not normalise aDateOnly, so every in-memory comparison involving adatefield answeredfalse. Adatenow compares as midnight UTC, the same rule the database uses. Guards that were silently dead start working on upgrade, in both directions:- a before-hook
rejectover a date (new.d != old.d, date vs date, date vs datetime) now fires; changed(<date>)no longer reports every update as a change;- a rule’s in-memory WITH CHECK over a date now agrees with SQL, so a row the rule’s USING refuses is also refused on write.
A descriptor that relied, knowingly or not, on such a guard never firing will now see writes refused. SQLite still differs from PostgreSQL for a date compared with a datetime at exactly midnight UTC of the same day (#318).
- a before-hook
-
A before-hook
"mutate": {"f": null}is judged, not thrown on (#326). The schema admits a JSONnullmutate value — a hand- or agent-written descriptor may hold one — but System.Text.Json never hands anulltoken to a converter, so the entry reached the before-hook compiler as a null and aNullReferenceExceptionescaped the validator: apply, its dry run and every management route that validates answered 500. The entry is now read as the null literal it was written as, so the rule the literal path already had applies — refused at the mutate slot on arequiredfield, accepted (the field is emptied) on an optional one. -
The OpenAPI document’s advertised origin carries the request’s path base (#130) — and it always did.
Microsoft.AspNetCore.OpenApibuildsservers[0].urlfrom the request’s scheme, host andPathBase, per request rather than once per document name, so a client resolving a path key against it reaches the endpoint underapp.UsePathBase("/alvo")and behind a proxy that setsX-Forwarded-Prefixfor a host told to trust it. What was broken was the record: the defect was documented as open in two architecture notes and in this changelog, and nothing measured the path-base half of that value — the scheme and host halves were pinned, so deletingPathBasefrom the framework’s own server-URL construction would have left the whole suite green while every path in the document became wrong by the prefix. Two facts now pin it, one per package. No production code changed; a bump ofMicrosoft.AspNetCore.OpenApiis henceforth gated by them. -
A 500 from the standalone host carries
alvo.dev/errors/internal(#119) — closed by verification rather than by a change. The slug, the opt-inAddAlvoProblemDetails()registration, the handler that logs the exception with its stack trace and renders Alvo’s document, and the standalone-pipeline facts that hold all of it were delivered with the host itself. The one thing left behind was a stale sentence indocs/architecture/data-api.mdclaiming nine problem-type slugs over an eleven-row table; the prose now namesAlvoProblemTypes.Allinstead of a number. -
A database constraint violation is now
409, naming the field, instead of500 internal(#138). A value another record already holds on auniquefield, and a delete anonDelete: "restrict"reference refuses, both reached the host as the provider’s own exception and rendered asalvo.dev/errors/internal— “an invariant Alvo itself relies on is broken”, which neither is: the caller’s request conflicts with stored state, which is what409means. Three costs, each its own defect: an agent could not repair the request (no pointer, no field, no fix suggestion, in a framework whose principle 4 is structured errors with one); a500invites a retry that can never succeed; and the operator was paged, with a stack trace, for an ordinary caller mistake.A new slug,
conflict, is a second409besideidempotency-conflictby the same ruleout-of-scopeis a second403by — the two have different fixes. One slug covers both constraint kinds, with the difference inviolations: codeuniqueand pointer/<field>for a collision, codereferencedand the empty pointer for arestrictrefusal (aDELETEhas no field to change). The refusal still discloses no value, no engine message and no constraint or index name, and therestrictcase names no entity either — which of the entities that may reference a row actually holds one is a fact about data the caller may not be able to read. A conflict confined to framework-managed columns keeps propagating as the broken invariant it is.The engine-specific decoding lives behind the driver’s own SQL seam, never in a
catchthat matches a message: PostgreSQL reads SQLSTATE23505/23503plus the constraint name, SQLite the extended result code2067/1555/787plus the columns its message names. Both are held to one inherited suite (#139), which caught two real engine differences the PostgreSQL-only e2e could not have:ExecuteDeleteon SQLite loses the extended result code, and Alvo’s runtime EF model declared no indexes at all, so the constraint-name resolution PostgreSQL depends on always came back empty. -
A duplicate in an idempotent create no longer costs ten transactions. The retry that converges a lost key race caught any storage write failure, so a duplicate was re-attempted ten times — about 450 ms — before surfacing. It is no longer a
DbException, so it leaves on the first attempt. The idempotency record’s own primary key is deliberately still untranslated, because losing that race is what the retry exists for. (#127. The attempt count is now asserted rather than described, since every outcome assertion passes on a build that retries ten times and then throws the same exception. Two paths still retry by design — that untranslated primary key, and a failure no dialect recognises, which must keep retrying or a genuine insert race would escape as a 500. The count is pinned on SQLite; the PostgreSQL leg is #139’s.)
This page is generated from CHANGELOG.md.