Skip to content
Alvo is pre-v0.1: the image runs from its edge tag, and no NuGet package or release is published yet.Pre-v0.1: no release yet.Roadmap and status

MMLib.Alvo

The Alvo core: schema registry, Data API, rule engine, events and management, registered with AddAlvo.

The Alvo core: schema registry, Data API, rule engine, events and management, registered with AddAlvo.

class

The AI connection a deployment pins in its own configuration.

public const string ConfigurationSection = "Alvo:Ai";

The configuration section this binds from.

public string? ApiKeySecretRef { get; set; }

Gets or sets the name of the secret holding the API key, resolved through the secret store.

A name rather than a value, so a deployment whose secrets come from Key Vault, a mounted file or the encrypted table all configure the connection the same way — and none of them puts the credential in configuration.

public string? Endpoint { get; set; }

Gets or sets the base address to dial, e.g. http://localhost:11434/v1.

public string? Kind { get; set; }

Gets or sets which protocol the endpoint speaks — openai-compatible or azure-openai.

The wire spelling rather than the enum’s, because this is what an operator types into a YAML file and OpenAiCompatible is not.

public string? Model { get; set; }

Gets or sets the model or deployment name to ask for.

class

Configuration for the generated HTTP Data API. Infrastructure only, never domain input: which entities exist, and what a caller may do to them, comes from the project descriptor (IDescriptorSource) — the “descriptor ≠ options” rule (docs/architecture/extensibility.md rule 6) is what keeps a backend’s shape out of appsettings.json.

public int DefaultPageSize { get; set; }

The page size used when a request names none. Default 50.

public int MaxBatchRows { get; set; }

The most rows one batch request may carry. Default 1000.

Not MaxPayloadKeys, and that is the whole reason this exists. The key bound counts property names at every depth, so a batch of N rows with K fields spends 1 + N·K of it — about a hundred rows for a five-field entity. A batch refused by that bound is told it sent too many fields, which is advice about the wrong thing — and it makes this bound unreachable over HTTP for any entity with more than one field. So the batch body’s shape scan resets the key counter as each row opens: MaxPayloadKeys is spent per row, which is what that number has always meant on a single write, and this one is spent on rows.

Chosen rather than measured to a ceiling, and the difference is stated because MaxTerms had a real one to point at. Both shipped engines stayed linear to 5000 rows over a policed entity and nothing failed, so two properties decide the number instead: a batch holds each row’s lock until it commits, and 1000 rows is 893 ms of that on PostgreSQL against 3.1 s at 5000; and one request allocates ~195 MB at 1000 against ~960 MB at 5000, which is a cheap denial-of-service lever for one authenticated caller. It also matches MaxInCandidates, the framework’s other “how many things may one request name” count.

public int MaxIdempotencyKeyBytes { get; set; }

The longest Idempotency-Key a create will accept, in UTF-8 bytes. Defaults to MaxKeyBytes, and may only be lowered.

Bytes, and the unit is load-bearing rather than pedantry. The bound exists because the key is half of the idempotency record’s composite primary key and PostgreSQL caps a btree index entry at roughly 2700 bytes. Counted in UTF-16 string.Length — which is how this was first written — a key of multi-byte characters passes a bound of 4000 “characters” while being up to 16 000 bytes, handing storage exactly the over-long index entry the bound exists to prevent. A bound whose unit differs from its justification is not a bound.

The number itself lives on the port (MaxKeyBytes), which is where the rule is enforced for every caller including an embedded host that never goes through HTTP. This option exists so a host can be stricter than the port; the startup validator refuses anything above the port’s number, because a request layer that claimed to accept a key the port will reject would answer 201 to a request that cannot be recorded.

A longer key is refused, never truncated: two keys that differ only past the cut would become one key, so truncation turns two different requests into a replay of the first — silently, and in the direction that loses the second caller’s row.

public int MaxPageSize { get; set; }

The largest page a request may ask for. Default 200. Server-enforced rather than advisory: a maximum is required, because an unbounded limit is a denial-of-service one query long.

public int MaxPayloadDepth { get; set; }

How deeply a request body may nest. Default 32.

public int MaxPayloadKeys { get; set; }

How many property names a request body may carry in total, at any depth. Default 512.

The bound MaxPayloadDepth misses: a wide object escapes a depth cap entirely, which is exactly why MaxTerms exists beside MaxDepth. It counts at every depth rather than at the top level, because a top-level-only count is not a bound at all: one level of nesting ({"name":{…150 000 keys…}}) satisfies it while still costing the memory the count exists to cap. 512 is far more fields than any entity the schema admits declares, so a legitimate payload never approaches it.

public int MaxRequestBodyBytes { get; set; }

The largest request body a write endpoint will read. Default 1 MiB.

The three payload bounds exist because the body parser is reachable before policy and without authentication — an anonymous caller’s POST is parsed before the port has any say — so an unbounded parser is a denial of service that needs no credential at all. Each bound is enforced while reading, never on a finished document: a limit checked after parsing has already paid the cost it exists to prevent. 1 MiB is generous for a descriptor-shaped record (a flat map of declared fields) and small enough that a request cannot exhaust a host by arriving.

public string RoutePrefix { get; set; }

The route prefix every generated endpoint sits under. Default /api.

Configurable because an embedded host is mounting Alvo beside its own endpoints and must be able to keep the two apart. A leading and trailing / is normalized away, so "api", "/api" and "/api/" all mount at the same place — and a prefix that is nothing but slashes or whitespace ("", "/", "//", " / ") reduces to the empty string, mounting the entities at the root as /owners. Anything that cannot reduce to a legal route pattern — an interior empty segment, a route-parameter brace, a wildcard, a query or fragment marker — is refused at startup rather than left to fail when the first route is built.

static class

Every problem type the Data API answers with, as the slug that identifies it — the machine-readable half of an RFC 9457 problem document, enumerated in one place so nothing can be spelled twice.

A slug keys on the refusal’s kind, never on its reason. RFC 9457 §3.1.1 makes type the classification a client is allowed to branch on and detail prose that “ought not be parsed” — so the kinds here are exactly the distinctions Alvo is willing to commit to. A slug encoding why policy refused would become the schema-and-data oracle every deny reason in the framework is worded to avoid: the wording of IPolicyEngine’s reasons is deliberately free of the entity, the row, and whether it exists, and a parseable classification beside it would hand back what the prose withholds. Forbidden is therefore one slug for every policy refusal, and NotFound is one slug whether the row is absent or merely invisible.

OutOfScope is a legitimate second 403 by the same rule, not an exception to it: a key’s own scope is a fact about the caller’s credential — knowable to whoever issued it — rather than about whether data exists, and the two have different fixes (grant the key a scope; change a rule). A caller who cannot tell them apart re-issues the wrong one.

Public because it is the contract: an agent or an embedded host branching on a refusal needs the same constants the framework emits, and a copied string literal is how the two come to disagree.

public const string BaseUri = "https://alvo.dev/errors/";

The namespace every problem type is minted under. A resolvable URI rather than a bare token, as RFC 9457 §3.1.1 asks for, so the classification doubles as the place its documentation lives.

public const string Conflict = "conflict";

The request collides with stored state a database constraint guards (409).

A second 409 beside IdempotencyConflict, by the same rule OutOfScope is a second 403 by. The two have different fixes and a caller can act on the difference: an idempotency conflict is repaired with a fresh key and the same body, and this one with a different body (or by removing what stands in the way). A caller who cannot tell them apart retries the wrong one forever.

One slug for both kinds, and the violations array carries the difference. A slug keys on the refusal’s kind, and “your request conflicts with stored state” is one kind; which constraint, and which field, is per-violation detail with its own stable code (unique, referenced) and pointer. Splitting the slug would put the schema’s shape into the classification an agent branches on.

public const string DestructiveChange = "destructive-change";

The change would discard data and the caller did not ask for that (409).

A third 409, and it is a different fix from both of the others. An IdempotencyConflict is repaired with a fresh key, a Conflict with a different value — and this one with the same request plus an explicit destructive allowance, or with a descriptor that keeps what the plan would drop. A caller who cannot tell the three apart retries the wrong one.

Not Validation: nothing about the descriptor is malformed. It is a well-formed request that collides with data already stored, which is what 409 means.

public const string Forbidden = "forbidden";

A policy refused the operation (403).

public const string FunctionFailed = "function-failed";

A CEL function failed while the write was evaluated, so nothing was written (500).

public const string IdempotencyConflict = "idempotency-conflict";

An idempotency key was reused for a different request (409).

public const string Internal = "internal";

An invariant Alvo itself relies on is broken (500).

public const string MalformedQuery = "malformed-query";

The query string or the request body is malformed (422) — the shape is wrong, nothing is hidden.

One slug for both, because it is the channel IAlvoData’s ArgumentException family lands on too: “the query or payload is malformed” is one diagnosis whether this layer or the port noticed it, and a second slug would let a caller tell which layer looked at their request.

public const string NotFound = "not-found";

The row does not exist, or the caller’s policy excludes it — indistinguishably (404).

public const string OutOfScope = "out-of-scope";

The presented API key’s scopes do not cover this entity and operation (403).

public const string PreconditionFailed = "precondition-failed";

The write carried a version the stored row does not have (412).

public const string PreconditionRequired = "precondition-required";

The write requires a precondition and carried none (428).

RFC 6585 §3. Distinct from PreconditionFailed, which is a precondition that was sent and did not hold: this one is the header’s absence, and the fix is to read the current revision and send it. Applying without one is a lost update with nothing to detect it, on the one document that defines the whole backend — so it is refused rather than defaulted.

public const string Unauthenticated = "unauthenticated";

A credential was presented and cannot be used (401).

public const string UnreadableRequest = "unreadable-request";

The server refused the request before Alvo could read it (400, 408 or 413).

The one slug whose status is not fixed, and deliberately so: it keys on the kind of refusal — the request never became something Alvo could look at — while the status says which limit the server applied (a body over MaxRequestBodySize, framing that broke mid-upload, a body arriving too slowly). Splitting it per status would encode the server’s configuration in the classification an agent branches on, and the fix is the same for all three: send a different request.

public const string UnsupportedMediaType = "unsupported-media-type";

The request carried a body that is not declared as JSON, or carried no Content-Type at all (415).

Distinct from MalformedQuery, and it is the kind that separates them. This one means Alvo never looked at the content, because the caller did not declare it as JSON, and the fix is a header. MalformedQuery means Alvo read the content and refused it, and the fix is the body. It is distinct from UnreadableRequest too — that one is the web server refusing before Alvo was reached at all.

Answered on every route that reads a request body, and on no other — a read that parses no body cannot reach it, which is why the generated document lists it on exactly seven operations per entity.

public const string Validation = "validation";

Schema-derived validation refused the request body (422).

public static IReadOnlyList<string> All { get; }

Every slug this catalogue declares. Enumerated rather than discovered by reflection so a fact can assert the catalogue and the code agree without the assertion being satisfied by its own subject.

public static string UriOf(string slug)

The full problem type URI for one slug.

  • slug — One of this type’s slugs.

class

Options for the framework’s built-in dev API-key auth mechanism: a fixed list of keys configured in-process rather than issued and persisted by a provider. Configuration-bindable so a host can populate DevKeys from appsettings.json or environment variables.

public IList<AlvoDevApiKey> DevKeys { get; }

Gets the configured dev API keys.

public string HeaderName { get; init; }

Gets the HTTP header a presented API key is read from, consumed by the HTTP Data API.

public string TenantHeaderName { get; init; }

Gets the HTTP header the tenant a caller asks to act in is read from, consumed by the HTTP Data API.

A header, and not a claim or a subdomain, because tenant resolution is host/environment configuration and never descriptor input (schema tenancy says so in as many words). It is only ever a confirmation of the tenant the presented key was issued for — TenantResolver refuses any other value — so this header can never widen what a credential grants. It is configurable for the same reason HeaderName is: an embedded host may already own a header by that name.

class

A single dev API key, as configured directly rather than issued by a provider.

public DateTimeOffset? ExpiresAt { get; set; }

Gets or sets when this key expires, if ever.

public string KeyId { get; set; }

Gets or sets the key’s public identifier.

public IList<string> Roles { get; }

Gets the names of the roles this key grants.

public IList<string> Scopes { get; }

Gets the entity/access scopes this key grants, in the descriptor form "<entity|*>:<read|write>".

public string Secret { get; set; }

Gets or sets the plaintext secret as configured; retained on the options instance for the process lifetime — a dev mechanism only, not a production issuance path.

public Guid? Tenant { get; set; }

Gets or sets the tenant this key is scoped to, if any.

public Guid User { get; set; }

Gets or sets the user this key authenticates as.

class

How the outbox dispatcher drains the event queue: whether it runs at all, how often it polls, how much it claims at a time, how many attempts an event gets, and how long a claim holds. Bound from the SectionName configuration section by AddAlvo and validated at startup.

In an environment variable the keys are spelled with a double underscore for the separator — Alvo__Events__Enabled, Alvo__Events__BatchSize, and so on.

Every default here is a latency-against-load trade, with one exception.MaxAttempts is the only bound on an event that can never be delivered, because this build has no dead-letter queue: past the ceiling the entry stops being claimed and stays in the outbox with its dispatched_at unset, so it is countable and inspectable rather than deleted or moved. Raising it raises how long one poison event is retried; lowering it gives a genuinely transient outage less room.

public const string SectionName = "Alvo:Events";

The configuration section these options bind from: Alvo:Events.

public int BatchSize { get; set; }

Gets or sets the most entries one claim takes. Defaults to 100, and must be at least 1.

public TimeSpan ClaimLease { get; set; }

Gets or sets how long a claim holds before another claimant may take the entry back. Defaults to five minutes, and must be longer than PollInterval.

The lease is what recovers an entry a process died holding; nothing else does. It must outlast the poll interval, because a lease shorter than the interval re-claims an entry that is still in flight on the very next tick — a duplicate delivery per tick rather than at-least-once delivery.

public bool Enabled { get; set; }

Gets or sets whether this process drains the outbox. Defaults to true.

Switching it off stops delivery, never emission: every write still appends its event on its own transaction, so the queue keeps filling and a later process — or this one, restarted — delivers what accumulated. That is what makes it the switch for the replicas that must not dispatch, rather than a way to turn events off.

public int MaxAttempts { get; set; }

Gets or sets how many times one event may be claimed before it is left alone. Defaults to 10, and must be at least 1.

This build’s stand-in for a dead-letter queue, and the only bound on a delivery that fails forever. A failed attempt is never classified — a 500, a 404, a DNS failure and a timeout are indistinguishable at delivery from an endpoint whose deploy is thirty seconds out — so the ceiling is the one place the retry stops.

public TimeSpan PollInterval { get; set; }

Gets or sets how long the pump waits after finding nothing to claim, before claiming again. Defaults to one second, and must be greater than zero.

Waited only after an empty claim, so a queue with a backlog drains at full speed and this is the idle-latency setting rather than a throughput limit.

public IList<string> WebhookAllowedNetworks { get; }

Gets the non-public networks, in CIDR notation, a webhook may be delivered to. Empty by default, which allows only globally reachable addresses — and loopback, when the endpoint names it literally.

Webhook delivery is default-deny for private, loopback, link-local (cloud instance metadata), CGNAT, multicast and reserved destinations, judged on the address the name resolves to at connect time, so a public-looking name that points inside the network is refused like the address itself. An embedded host that delivers to its own internal services lists their networks here, such as 10.20.0.0/16 or fd00:20::/32; an entry that is not CIDR notation is refused at startup. In an environment variable each entry is indexed: Alvo__Events__WebhookAllowedNetworks__0.

A list of networks rather than an on/off switch, so admitting one internal service does not also admit the metadata endpoint. A listed network overrides every deny, so list the narrowest one that works.

class

Infrastructure configuration for the Management API — where it mounts, and how this deployment describes itself. Never domain input: what a project is, and who may manage it, comes from the descriptor.

The section is Alvo:Management, spelled like Alvo:Schema and Alvo:Events. In an environment variable the key is Alvo__Management__RoutePrefix.

Public for the reason AlvoApiOptions is: an embedded host mounts Alvo beside its own endpoints and has to be able to move the prefix out of the way.

public const string SectionName = "Alvo:Management";

The configuration section these options bind from: Alvo:Management.

public string RoutePrefix { get; set; }

The route prefix every management endpoint sits under. Default /management.

class

How much the boot sequence is allowed to do to the project schema. Bound from the SectionName configuration section by AddAlvo and validated at startup.

A host that says nothing gets Apply and no destructive allowance — the descriptor is applied on boot, and no boot in any mode may discard data without AllowDestructive. The two halves are deliberately separate: the mode decides whether a process may bring the database up to the descriptor, and the allowance decides whether it may throw anything away doing so. Only the first is defaulted permissively.

In an environment variable the keys are spelled with a double underscore for the separator — Alvo__Schema__Startup and Alvo__Schema__AllowDestructive.

public const string SectionName = "Alvo:Schema";

The configuration section these options bind from: Alvo:Schema.

public bool AllowDestructive { get; set; }

Gets or sets whether a boot may apply a plan that drops or narrows something — the guardrail that separates Apply from data loss. Defaults to false, so a destructive plan is refused even under Apply.

public string? Project { get; set; }

Gets or sets the project a dashboard-first host boots — the project whose stored descriptor the boot reads when no IDescriptorSource is configured. null in code-first mode, where the descriptor names the project itself.

Setting it in code-first mode is refused rather than ignored: a host that configured both a descriptor source and a project name has said two things that can disagree, and silently preferring one of them is how a deployment ends up serving a project nobody chose.

public AlvoSchemaStartupMode Startup { get; set; }

Gets or sets what a boot does when the descriptor has drifted from the applied schema. Defaults to Apply, which brings the database up to the descriptor and still refuses any step that would discard data.

This default is deliberately notdefault(AlvoSchemaStartupMode), which is Verify. The two answer different questions: the zero value is where a value that went missing lands, and it must be the mode that touches nothing; this initializer is where a host that deliberately said nothing lands, and that host pointed Alvo at its own database on purpose. Anything that collapses the two — reading the property’s default off the enum, or moving Apply to zero to “match” — loses one of the two guarantees.

static class

The generated Data API’s endpoint seam, owned by the core package. Deliberately separate from the DI seam (docs/architecture/extensibility.md rule 10): adding endpoints never changes how Alvo is registered, and registering Alvo never exposes an endpoint.

public static IEndpointConventionBuilder MapAlvoDataApi(this IEndpointRouteBuilder endpoints)

Registers the Data API’s endpoint data source, which maps one minimal-API delegate per operation per entity in the applied schema.

The data source is registered even when the schema declares nothing, and that is load-bearing: WebApplicationBuilder decides whether to add UseRouting/UseEndpoints at all from DataSources.Count > 0 — it counts sources, not endpoints — so registering an empty one is both necessary and sufficient, and registering none would leave routing out of the pipeline where no later priming could put it back. Alvo never calls UseRouting or UseEndpoints on the host’s behalf, which the routing docs’ guidance for library authors forbids outright.

And for that input the refusal costs Alvo its readiness, not the host its matcher. A schema neither guard will route leaves this source with an empty endpoint table and records the reason on AlvoBootState, so /health/ready reports Failed while /health/live keeps answering. Throwing out of an EndpointDataSource instead — which is what this did — took down the composite the framework matches every request through, liveness included, and a failing liveness probe is how a pod gets killed and restart-looped for a schema no restart can fix.

Every mapped endpoint carries the API-key context filter — attached in the same call as the operation marker and as the host’s own conventions — so nothing this framework maps has a path to IAlvoData that skips the authorization seam. A convention the host attaches receives the endpoint builder and could take it away again; that is host code deciding to dismantle its own pipeline, which it could already do by substituting IPolicyEngine, and it is not what this sentence claims.

It returns a convention builder rather than the route builder it was given, which is what every ASP.NET Core Map* does — MapHealthChecks and MapControllers included. The capability was reachable before: app.MapGroup("").MapAlvoDataApi() plus conventions on the group worked, because GetGroupedEndpoints forwards the group’s context to the nested minimal-API sources. What it was not, was discoverable. Conventions have to be attached before the first request materialises the route table; one attached after is refused, because a frozen table cannot honour it and a silently dropped RequireRateLimiting is a rate limiter a host believes it has.

MapAlvo() deliberately still returns the route builder, and MapAlvoHealth() is not chainable at all: one convention builder over the probes and the Data API would let a host attach an authorization policy to /health/live, and a container probe presents no credential — that is a container killed and restart-looped by its own liveness gate. A host that wants conventions calls the parts, which is already the documented composition.

  • endpoints — The endpoint route builder to map onto.

Returns: A convention builder over the routes this call will materialise, so a host can attach RequireRateLimiting, an authorization policy, output caching or a telemetry tag to Alvo’s generated endpoints and to nothing else.

static class

Alvo’s umbrella endpoint seam: one call that maps everything AddAlvo registered and made reachable.

public static IEndpointRouteBuilder MapAlvo(this IEndpointRouteBuilder endpoints)

Maps Alvo’s probe endpoints, the generated Data API and the Management API — the whole HTTP surface a host gets from the framework, in one call.

It is a composition, not a replacement.MapAlvoHealth(), MapAlvoDataApi() and MapAlvoManagementApi() stay public for a host that wants the pieces — mounted under different route groups, say, or with only some of them — exactly as MapControllers coexists with the finer-grained controller mappings. This method is defined as those three calls and nothing else, and a test asserts the mappings produce the same endpoint data sources, so the umbrella cannot drift from its parts.

Health maps first, and the order is load-bearing.MapAlvoDataApi() refuses a host whose Data API services are absent, and an operator facing that refusal needs a container that can still be probed: mapping health second would leave one that answers nothing at all, which an orchestrator cannot tell from a process that is merely slow to start.

It returns the route builder, so the management surface’s convention builder is discarded here. A host that wants a convention over the management routes alone — RequireRateLimiting, most obviously, which this package never applies on a host’s behalf — calls MapAlvoManagementApi() itself and keeps what it returns. Widening this method’s return type to carry one builder out of three would privilege one part of the composition over the others, and MapAlvo’s value is that it chains like every other Map* a host writes.

Calling it stays mandatory, deliberately. Nothing Alvo registers is reachable over HTTP until a host maps it — the routing documentation’s guidance for library authors forbids a library from calling UseRouting/UseEndpoints on a host’s behalf, and nothing may self-register an endpoint data source outside an explicit Map* call. What this call does not require is an order: it may run before or after the schema exists, because Alvo’s boot primes it before the server binds and the Data API’s routes materialise from that on first enumeration.

It does not register AddAlvoProblemDetails()’s error handling, which stays opt-in: an embedded host has its own, and taking over the shape of UseExceptionHandler’s document inside someone else’s application is worse than one explicit call (design deviation 36).

  • endpoints — The endpoint route builder to map onto.

Returns: The same builder, for chaining.

static class

Alvo’s probe endpoints, owned by the core package so an embedded host gets the same two routes a container does. Deliberately separate from the DI seam (docs/architecture/extensibility.md rule 10).

public static IEndpointRouteBuilder MapAlvoHealth(this IEndpointRouteBuilder endpoints)

Maps LivenessPath and ReadinessPath.

Call it whenever you like — the boot does not have to have run yet. Readiness reads AlvoBootState on every request, and that state reports Pending until a boot publishes something, so a host that mapped health and then refused to boot answers 503 rather than throwing into the probe. Liveness answers 200 throughout, which is the point of splitting them: the process is up, and only its readiness is in question.

The two are configured oppositely, on purpose. Liveness evaluates zero checks, so no health check anyone adds later can start killing containers under load. Readiness evaluates every check tagged ReadyTag, so a check registered without much thought lands where being wrong costs traffic rather than the process. Alvo contributes two: the schema-applied check and the store-reachability one. Both report Unhealthy and never Degraded — the framework maps Degraded to 200 and Kubernetes counts any 2xx as success, so a degraded gate is no gate at all.

Neither route carries a credential, and readiness therefore publishes the phase and nothing else. A container probe presents nothing to authenticate with, so both are anonymous by construction; and Failure — the reason a refused boot recorded — is the database provider’s own message for a stage-1 or stage-2 failure and can carry a connection string. The operator reads that on stderr and in the log; the probe reads Pending, Ready or Failed (design deviation 59).

Neither response is cacheable: AllowCachingResponses defaults to false, which is what makes the framework send Cache-Control: no-store, no-cache on both — so there is nothing to configure here, and nothing to regress silently either.

  • endpoints — The endpoint route builder to map onto.

Returns: The same builder, for chaining.

AlvoManagementEndpointRouteBuilderExtensions

Section titled “AlvoManagementEndpointRouteBuilderExtensions”

static class

The Management API’s endpoint seam, owned by the core package and deliberately separate from the DI seam (docs/architecture/extensibility.md rule 10): registering Alvo never exposes an endpoint.

public static IEndpointConventionBuilder MapAlvoManagementApi(this IEndpointRouteBuilder endpoints)

Maps one minimal-API delegate per IAlvoManagement member under RoutePrefix.

Nothing mapped here is reachable without an explicit grant. Every route carries the gate the descriptor’s access block compiles, and a caller that block does not name is refused — as is a caller presenting no credential at all. A project with no access block therefore admits nobody but the deployment’s bootstrap administrator, which is what default-deny means here.

MapAlvo() calls this, and calling it directly is what a host does for a convention. The umbrella discards the builder returned here, so a host that wants RequireRateLimiting, a CORS policy or an authorization convention over the management routes alone maps the pieces itself — MapAlvoHealth(), MapAlvoDataApi() and this — and keeps what this returns. Throttling in particular is deliberately a host decision: this package ships no rate limiter and applies none.

  • endpoints — The endpoint route builder to map onto.

Returns: A convention builder over the management routes, and over nothing else.

Namespace Microsoft.Extensions.DependencyInjection

Section titled “Namespace Microsoft.Extensions.DependencyInjection”

static class

Infrastructure-selection extensions on IAlvoBuilder owned by the core package.

public static IAlvoBuilder AddCelFunction(this IAlvoBuilder builder, string name, Delegate function, string? summary = null)

Registers a host function the descriptor’s CEL may call in a hook condition and a before-hook mutate value — and nowhere else: rules and computed fields are evaluated by the database.

Up to four parameters of String, Int64, Int32, Decimal, Boolean, DateTimeOffset or Guid (or a nullable one), and a result of the same set. A null argument for a non-nullable parameter makes the call null without invoking the function; a present argument that does not fit its parameter (a value past Int32’s range for an Int32) fails the call without invoking it, exactly as a throw does — it never reads as null. Everything is checked here, at the call, so a mistake fails startup rather than an apply.

The function is host code inside the write’s transaction. It must be synchronous, fast, thread-safe and free of side effects; it runs once per evaluation with no timeout. It captures what it closes over for the host’s lifetime (no DI scope).

If it throws (or an argument does not fit), the evaluation fails closed, and what that looks like depends on the caller: a Data API write is rolled back and answers HTTP 500 …/errors/function-failed naming the function; an in-process IAlvoData caller (a host endpoint, the dashboard) receives an exception and nothing is written (the dashboard shows its generic fault); in an after-hook condition the after-hook is dropped and a Warning is logged. So throw on a present input the function cannot answer for: a null return makes the call null, a condition over null does not fire, and a reject gated on the function would let that input through. Keep null for an input that really has no value.

What the function throws is logged whole — its message and stack trace, at Error for a write and at Warning for an after-hook condition — and never shown to the caller. So never put caller data (a field’s value, an argument) in an exception message: it would land in every log sink the host ships to.

Alvo’s tenant filter does not reach inside the function. A function that reads stored data must take the tenant as a parameter and filter by it itself. On a tenant-scoped entity pass the row’s own new.tenant_id, which a condition and a mutate both read; @tenant.id works in a condition only, because the Mutate profile refuses it.

A changed meaning deserves a new name, so stored descriptors keep theirs. Removing or renaming a registered function makes a stored descriptor that calls it fail the apply at boot, refused as calling an unknown function. Only this host knows the function: the standalone image and the CLI refuse a descriptor that calls it as an unknown function.

  • builder — The Alvo builder.
  • name — The CEL name: a lower-case ASCII letter, then ASCII letters, digits or _, at most 64 characters, and not a built-in (int, string, contains, …), a CEL keyword, macro or reserved word, or a standard CEL type or function name Alvo does not build yet (uint, matches, duration, …).
  • function — The implementation, e.g. (string phone) => ….
  • summary — One sentence for discovery (cel/functions, the assistant). The name and summary are visible to every Viewer of the management API: put no secrets or internal-only wording in them.

Returns: The same builder, for chaining.

public static IAlvoBuilder FromDescriptor(this IAlvoBuilder builder, string path)

Selects the project descriptor as a file on disk.

  • builder — The Alvo builder.
  • path — Path to the project descriptor JSON file.

Returns: The same builder, for chaining.

public static IAlvoBuilder UseSchemaPrefix(this IAlvoBuilder builder, string prefix)

Sets the prefix Alvo uses for the database objects it owns (default "alvo").

  • builder — The Alvo builder.
  • prefix — The schema prefix; lower snake_case, 1–16 characters.

Returns: The same builder, for chaining.

static class

The generated Data API’s registration seam, owned by the core package. In Microsoft.Extensions.DependencyInjection per the extensibility rules (docs/architecture/extensibility.md rule 1), like every other Alvo builder extension; the endpoint seam is a separate class in Microsoft.AspNetCore.Builder, per rule 10.

public static IAlvoBuilder AddDataApi(this IAlvoBuilder builder, Action<AlvoApiOptions>? configure = null)

Configures the generated Data API, which AddAlvo has already registered.

Additive and idempotent (Add{Thing} in the fixed verb taxonomy, rule 7): calling it twice is not a duplicate, and a second call carrying no configure does not undo the first. Order does not matter either — the default registration contributes no configure action of its own, so a host’s value wins whether it was written before or after AddAlvo.

  • builder — The Alvo builder.
  • configure — Configures AlvoApiOptions — the route prefix and the paging limits.

Returns: The same builder, for chaining.

static class

Lets a host hand Alvo the rendering of an unhandled failure — the standalone host’s decision, and an embedded host’s to decline.

public static IServiceCollection AddAlvoProblemDetails(this IServiceCollection services)

Registers the exception handler that answers an unhandled failure from one of Alvo’s own endpoints with Alvo’s problem document. Pair it with app.UseExceptionHandler().

The scope is Alvo’s generated routes, not the pipeline. The handler declines a failure from any other endpoint, so an IExceptionHandler a host registers after this call still runs for the host’s own endpoints — the framework stops at the first handler that claims a failure, and a version of this that claimed all of them silently deleted the host’s error contract from its own 500s. A host that wants Alvo’s document everywhere therefore does not get it, and that is the trade: an embedded host owning its rendering is the whole point of the opt-in.

AddProblemDetails() is registered alongside because UseExceptionHandler() refuses to configure a middleware with neither a handler path nor a problem-details service to fall back to. It is a real fallback rather than a formality: it is what answers a failure this handler declines and no host handler claims.

  • services — The service collection.

Returns: The same collection, for chaining.

static class

The single Alvo entry point: registers the core services and returns the builder every provider and feature attaches to.

public static IAlvoBuilder AddAlvo(this IServiceCollection services, Action<IAlvoBuilder>? configure = null)

Adds Alvo to services: AlvoOptions (validated at startup) and the code-first migration orchestrator. Attach a database provider (UseSqlite, UsePostgreSql) and a descriptor source (FromDescriptor) via configure or by calling the returned builder’s extension methods directly.

ISchemaRegistry arrives with the policy catalog provider, which implements it: the applied schema a data port validates a caller’s field names against is then always the one the rules judging the same request were compiled against. It reads an empty model until a descriptor is applied — no entity declared, so every entity and field name is refused — and a host with its own schema source registers its own and takes it over.

  • services — The service collection.
  • configure — Optional callback to attach providers and features to the builder.

Returns: The IAlvoBuilder, for further chaining outside configure.