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.Abstractions

The interface-first root of the Alvo dependency graph: every port, and nothing that implements one.

The interface-first root of the Alvo dependency graph: every port, and nothing that implements one.

class

Configuration options for Alvo.

public AlvoMode Mode { get; set; }

Gets or sets the deployment mode.

public string SchemaPrefix { get; set; }

Gets or sets the schema prefix for database objects.

interface

Builder interface for configuring Alvo.

IServiceCollection Services { get; }

Gets the dependency injection service collection.

interface

Administering the people who can sign in and manage a project.

A second contract, deliberately, rather than six more members on IAlvoUserStore. That port is a read the context resolver performs on every request, and every host that registers Alvo implements it. This one is written by an administrator through a screen, and a host with a read-only directory can implement the first and decline the second. Folding them together would make the ordinary case carry the administrative one.

Every member may refuse by name. A host mirroring a corporate directory refuses CreateAsync because accounts are not created here; a deployment on OIDC refuses IssueCredentialTokenAsync because it holds no password to reset. The refusal is NotSupportedException with a sentence an operator can act on, and a screen renders it in place of the control. What a host may not do is answer a write with success and change nothing.

The caller is ambient, not a parameter. Like IAlvoManagement, an implementation of this port is reached through the core’s guarded decorator, which reads the caller from IAlvoContextAccessor — the same principal the management routes publish. An implementation therefore never decides who may call it, which is what keeps authorization out of a swappable adapter where it would be optional by construction.

The bootstrap administrator is not a target of this surface. Two members refuse it by name — see each one — because it is the identity the whole default-deny story rests on: a project whose access block admits nobody still has exactly one person who can fix it.

Task<AlvoUser> ClearLockoutAsync(UserId user, CancellationToken cancellationToken = default)

Ends a temporary lockout from failed sign-ins now, and forgets the failed attempts.

Refuses a disabled person, with ManagementRequestException. A disable and a lockout share a column in some stores, and an implementation that cleared it for a disabled person would let them back in by a door that decides nothing about a disable. Letting a disabled person back in is SetDisabledAsync. The check is made on the person as stored at the call, in the same unit of work as the write, so a disable written meanwhile is never undone.

Sessions are untouched. Nothing about who holds a session changed, so, unlike a disable, it ends none and invalidates no credential token. The bootstrap administrator is not refused: ending their lockout gives back the one account that can always recover a project, rather than taking it away.

  • user — Whose lockout to end.
  • cancellationToken — Cancels the write.

Returns: The person as they now are.

Task<AlvoUser> CreateAsync(AlvoUserCreation creation, CancellationToken cancellationToken = default)

Creates a person who can sign in.

No password. A credential that travels as a value is readable by whoever handles it — the same reason this repository refuses a bootstrap password supplied as configuration. The new operator sets their own by redeeming a token from IssueCredentialTokenAsync — in the standalone image, on the dashboard’s set-password page.

  • creation — The address, the roles and the tenant.
  • cancellationToken — Cancels the write.

Returns: The person that now exists.

Task<AlvoCredentialToken> IssueCredentialTokenAsync(UserId user, CancellationToken cancellationToken = default)

Mints a single-use token with which a person sets their own password.

Refuses the bootstrap administrator by name. Without that, any administrator mints a token for the one account the descriptor’s access block does not govern, sets its password and signs in as it. The bootstrap credential comes from a mounted file and rotating it is a deployment operation.

Nothing in this build delivers the token. There is no mail transport, and templates is a warned subsystem whose reach is an after-hook on an entity write rather than an identity event. It is returned for an administrator to hand over out of band — the dashboard renders it as a link to its set-password page, where the person redeems it once and then signs in — and a screen that renders it says so. Redeeming it ends every session the person held.

  • user — Who is setting a password.
  • cancellationToken — Cancels the write.

Returns: The token and when it stops working.

Task<AlvoUserPage> ListAsync(AlvoUserQuery query, CancellationToken cancellationToken = default)

One page of the people on this project.

Paged from the start rather than later: the port it grew out of returns every row in no order, which is one render for a build with one account and the wrong shape at the thousands a real deployment has. Widening a port nobody implements twice yet is cheap.

  • query — What to return.
  • cancellationToken — Cancels the read.

Returns: The page.

Task<AlvoUser> SetDisabledAsync(UserId user, bool disabled, CancellationToken cancellationToken = default)

Bars a person from signing in, or lets them back.

Refuses the bootstrap administrator by name. The context resolver answers null for a disabled account before anything consults the bootstrap branch, and the seed does not reset an existing row — so a deployment whose access block admits nobody else, which is the default, would be locked out of its own management surface with no way back but editing the identity database by hand.

A disable ends every session the person holds and every credential token outstanding for them, and letting them back in does not revive either: they sign in again, and a person who had not set a password yet needs a new token.

  • user — Whom to disable or restore.
  • disabled — Whether they are barred.
  • cancellationToken — Cancels the write.

Returns: The person as they now are.

Task<AlvoUser> SetRolesAsync(UserId user, IReadOnlyList<string> roleNames, CancellationToken cancellationToken = default)

Replaces a person’s role membership.

The names are stored as given, declared or not: membership outlives a descriptor edit, and the intersection with the declared catalogue happens where the context is minted.

  • user — Whose roles to replace.
  • roleNames — The roles they should have.
  • cancellationToken — Cancels the write.

Returns: The person as they now are.

Task<AlvoUser> SetTenantAsync(UserId user, TenantId? tenant, CancellationToken cancellationToken = default)

Grants, changes or removes the one tenant a person acts in.

  • user — Whose tenant to set.
  • tenant — The tenant, or null to remove the grant.
  • cancellationToken — Cancels the write.

Returns: The person as they now are.

interface

The ambient, per-request accessor for the resolved caller. This is availability, not enforcement: IAlvoData still takes the AlvoContext as an explicit parameter, because the outbox dispatcher, after-hooks and automation actions run with no request scope and would find nothing here.

AlvoPrincipal? Principal { get; set; }

Gets or sets the principal resolved for the current request, if any.

interface

Resolves the presented credential into an AlvoPrincipal. ASP.NET-free by design: it takes the presented key, never an HttpContext, so it works identically in standalone and embedded mode.

ValueTask<AlvoPrincipal?> ResolveAsync(string? presentedKey, string? requestedTenant, CancellationToken cancellationToken)

Resolves a presented credential. Returns null — deny, never a partially-trusted principal — for a credential that is absent, malformed, expired, revoked, or for a mismatched requested tenant.

  • presentedKey — The raw credential presented by the caller, if any.
  • requestedTenant — The tenant the caller asked to act in, if any.
  • cancellationToken — A token to cancel resolution.

interface

The single seam every Alvo data operation goes through: policy is enforced inside an implementation of this port, not layered on top of it and not left to the caller. There is no way to read or write a row without going through IPolicyEngine first — this is the port the whole security core (context, CEL, tenancy, the rule engine) exists to make enforceable, and PR2’s SQLite/PostgreSQL implementations are held to the identical adversarial suite (MMLib.Alvo.Testing.AlvoDataAdversarialTests) this reference implementation is proven against first.

Why every member takes AlvoContext explicitly. The obvious alternative — an ambient accessor resolving the current caller from ASP.NET Core’s ambient state — silently breaks the moment code runs outside a request: the outbox dispatcher, an after-hook, and an automation action all call into IAlvoData with no HTTP request in flight, so an ambient accessor there would resolve to an empty context or a leftover scope from whatever request last used the thread. A wrong or missing tenant on exactly those paths is catastrophic — a post-commit hook silently acting across every tenant’s data — so AlvoContext is a required parameter everywhere, forcing every call site to state explicitly who it is acting as (frequently System).

The failure contract, chosen so nothing leaks the existence of an invisible row. A row that exists but that the caller’s policy USING predicate excludes must read exactly like a row that was never there: GetAsync returns null, and UpdateAsync/DeleteAsync throw AlvoRecordNotFoundException — the same outcome an absent id produces. An operation that is denied outright (no policy configured for it at all, or a candidate write that fails its WITH CHECK predicate) instead throws AlvoAuthorizationException, because there the caller is not probing for a specific row’s existence; they are attempting something no policy permits at all. Neither exception’s message names the entity, the row id, or whether the row exists.

Six exception families, and the boundary between them is the contract — not a detail. A layer above this port (PR3’s RFC 7807 problem-details layer) has nothing but the exception type to map a status code from, so an implementation must place every refusal in exactly one of these:

FamilyMeans, and what a request layer should renderArgumentException and its derived types, **except ArgumentNullException**The query or payload is malformed. A filter past MaxDepth/MaxTerms/MaxInCandidates, a negative Limit, a paged read sorted by a nullable field, an is operand that is not null/true/false, an in operand that is not a list, a value the field’s own type cannot hold, a fractional bound against an integral field, or a null where a nested filter belongs. Nothing about the caller’s permissions is in question and nothing is being hidden: the shape is wrong. Render 422 with the message’s fix suggestion.

ArgumentNullException is excluded and belongs to the last family below, even though it derives from this one. No request can express a null argument. A null reaching a member of this port means its caller — the HTTP layer, a hook, an automation action — passed one where this contract forbids it, which is a broken invariant of the code rather than a malformed request. Rendered as a 422 it tells a caller to fix a request that was fine, and it swallows the stack trace a host’s logging exists to record. So every ArgumentNullException.ThrowIfNull guarding this port’s own parameters raises an implementation defect, never a caller error. PR3’s HTTP layer excludes it from the malformed-query arm for exactly this reason; the exclusion is stated here because a provider author reads this table and not that layer.

AlvoAuthorizationExceptionThe operation is not permitted. No policy allows it, a filter or sort names a field this caller may not read, a payload names a framework-managed or read-only field, or a candidate post-image fails WITH CHECK or the tenant scope. Render 403. AlvoPreconditionFailedExceptionThe write carried a version the stored row does not have. The row has been written since the caller read it, or the entity keeps no version of a row at all (no audit, so no VersionColumn) and cannot answer the question — refused rather than ignored, because a silently ignored precondition is a lost update the caller believes it prevented. Neither the request nor the caller’s permissions is at fault, so neither of the two families above fits. Render 412; the fix is to re-read and retry. AlvoIdempotencyConflictExceptionAn idempotency key was reused for a different request. Same Key, different Fingerprint: answering with the first row would silently discard the second payload, and creating a second row would break the promise the key exists to make. The payload itself is well-formed, so this is not the malformed-query channel. Render 409; the fix is a fresh key, not a corrected body. AlvoConstraintViolationExceptionThe request collides with stored state the database itself guards. A value another record already holds on a unique field, or a delete a ref declaring onDelete: "restrict" refuses. The payload is well-formed and every facet an implementation can check itself has already passed, so this is neither the malformed-query channel nor a policy refusal; what is wrong is the request’s relationship to rows the caller may not even be able to see. Render 409, naming the fields the exception carries — see its own remarks for what may and may not appear there.

An implementation must not let the provider’s exception escape as this family’s neighbour below. Rendered as a 500 it tells the caller nothing they can act on, invites a retry that cannot succeed, and pages an operator for an ordinary mistake. Provider-specific decoding belongs behind the driver’s own SQL seam — a constraint’s kind is engine-specific (an SQLSTATE, an extended result code, an error number) and must not be recovered by pattern-matching an exception’s message in a catch.

A collision confined to framework-managed columns is not this family. A caller cannot change id or tenant_id, so a conflict on those alone is an invariant the implementation relies on, and it belongs below with its stack trace intact.

InvalidOperationException, and ArgumentNullExceptionAn invariant the implementation itself relies on is broken — a schema this port cannot serve, a field the read model does not map, a bound value with no known origin. Never caused by a well-formed request from an authorized caller. Render 500.

ArgumentNullException belongs here rather than to the malformed-query family it derives from, because no request can express a null argument — the first row’s own aside carries the full reasoning. It is named in both rows on purpose: an implementer arrives at this table from whichever family their exception is in, and the exclusion was findable from one direction only.

The two shipped implementations are held to this by AlvoDataAdversarialTests.A_malformed_filter_is_refused_on_the_malformed_query_channel, which exists because they once gave four different answers to four malformed inputs — including AlvoAuthorizationException for an ordinary typo like status=is.hello, i.e. a 403 with no fix suggestion, in a framework whose principle 4 is structured errors with fix suggestions.

AlvoAuthorizationException’s and AlvoRecordNotFoundException’s message reaches the caller verbatim at this port boundary (an implementation is not required to further generalize IPolicyEngine’s own deny reason). That reason is already designed, at the policy layer, never to name the entity or echo caller-supplied text — except the tenant guard’s reason, which deliberately names “tenant” (a narrow, intentional oracle: whether an entity is tenant-scoped at all). A caller building an HTTP layer on this port that wants to withhold even that distinction must map the message to something more generic itself and log the original.

The framework-managed id/tenant_id columns are never caller-writable, but not symmetrically.id is rejected in both a CreateAsync and an UpdateAsync payload — it is assigned once, by the implementation, and never rewritten. tenant_id is different: it is legitimately caller-supplied on CreateAsync (a tenant-scoped entity’s WITH CHECK/TenantScope guards the candidate row’s post-image there, exactly like every other field the check predicate constrains), but rejected outright on UpdateAsync — a row can never move to another tenant once created. Both rejections are checked against the payload alone, before any row lookup runs, so a caller cannot use “was my id/tenant_id write rejected or not” to learn whether a given row id exists; both raise AlvoAuthorizationException, never AlvoRecordNotFoundException, since the row (if any) was never consulted.

A write payload may only name fields the entity’s schema declares. A key naming no field at all is refused with AlvoAuthorizationException — the same class of refusal every other unwritable-field rejection uses, never an ArgumentException — and the message names neither the entity nor the key, since the key is caller-supplied text and a message naming both answers “does this entity have a field called X?” one request at a time. An entity the implementation’s own schema does not know refuses the write outright rather than skipping the check: a mismatch between the policy catalog and the implementation’s schema must not be the one path on which an unvalidated payload reaches storage.

A write’s two concurrency channels, and where each one is decided. An AlvoPrecondition is the caller’s claim about the version they are changing, and an AlvoIdempotency token is their claim that this write may already have happened. Both are optional, and an implementation must honour three rules about them:

The precondition is compared inside the write transaction, against the row-locked pre-image the WITH CHECK verdict is already reached over — never against a row read on a second, earlier trip. That is what stops the comparison racing the write it guards: between an unlocked read and the write, a concurrent writer can advance the row and the precondition would have approved a lost update. No second read is needed or permitted; the pre-image is already there. An entity with no version column refuses a precondition rather than ignoring it (AlvoPreconditionFailedException, via EnsureSupported). A silently ignored If-Match is a lost update the caller believes it prevented — the worst of the three possible answers, because nothing tells them it happened. Decided from the schema alone, before any row lookup, so it cannot answer “does this row exist” either. Invisibility outranks the precondition. A row the caller’s USING predicate excludes raises AlvoRecordNotFoundException whichever precondition was supplied — never AlvoPreconditionFailedException. Ordered the other way round, “412 rather than 404” would confirm that a row exists to a caller who may not read it, one request at a time, which is precisely the oracle the failure contract above exists to close.

An idempotency record stores the ids of the rows the write touched, and a replay re-reads them under a freshly resolved get decision for the replaying caller — reading and masking through it. A replayed delete is the one that reads nothing: its rows are gone by construction, so the answer is the same “it is gone” the first call gave, produced without a read. Not under the create decision the call arrived with, and the reason is the one a future implementer has to know rather than rediscover: a create decision has no USING predicate by contract (Using is null — there is no stored row to filter when the decision is made), and a null USING renders as a constant true, so a create decision must never be used to read a stored row. Reading under get is what makes a replay unable to hand back a row the caller could not read directly, or a projection their own hidden set would not produce.

A caller whose get is denied outright is not refused a replay — the answer is id alone, with no row read performed: see CreateAsync’s idempotency parameter and return value for the safety argument. Nor is one whose re-read does not return the row — a configuredget whose own predicate excludes it, or a row deleted since: the answer is the id alone, which is what the fresh write answered when its get excluded the row, so a retry never reports a committed write as not found. The two cases are not told apart, because that would need a policy-free existence read; a batch replay likewise answers one entry per recorded row.

The record’s identity is the caller’s key plus a scope of (tenant, acting user) — see IdentityOf for why the user belongs in it and why that is identity rather than a column beside it. An anonymous caller has no identity to scope by, so a token from one is refused outright (EnsureUsableKey).

The returned key set and CLR types are part of the contract, not an implementation detail. A returned AlvoRecord carries every non-hidden field the schema declares for that entity, including framework-managed columns (id, and — on a tenant-scoped entity — tenant_id); masking removes only descriptor-declared hidden fields, never a framework column. Select is the one other thing that narrows this key set, and it never narrows it below two groups. A projected read returns the fields the projection named, plus every column For reports for the entity — the row key alone is what a keyset cursor is minted from — plus every field named in Sort, because no implementation can order by a column it did not read. Both exemptions are contract, not courtesy: a caller reading “the fields I selected” and receiving those plus a sort key has not been surprised, and one that received *fewer* would have lost its paging. Masking remains the only thing that removes a field the caller did ask for. Field values use the same CLR types AlvoRecord’s own remarks describe the interpreter reading (Guid for a uuid field, never a String or a byte array; DateTimeOffset for a timestamp; decimal for a decimal field), so a caller of this port — and the adversarial suite itself — can assert on a field’s value without first normalizing it.

Task<AlvoRecord> CreateAsync(string entity, IReadOnlyDictionary<string, object?> values, AlvoContext context, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Creates a row.

The row this returns is the row the store holds, re-read inside the write transaction, not the payload that was sent: that is what gives a database default, a framework-assigned audit value, and therefore a usable version a following AlvoPrecondition can carry.

A write is not a read, and its answer is not a way around get. PostgreSQL RLS makes INSERT … RETURNING satisfy the SELECT policy and fails the statement otherwise; an implementation of this port answers the id alone instead, because the write was authorized and has happened. The same rule holds for UpdateAsync, ReplaceAsync, CreateManyAsync and UpdateManyAsync.

  • entity — The entity name.
  • values — The field values to write; the id is always assigned by the implementation.
  • context — The caller performing the create.
  • idempotency — The caller’s idempotency token, or null for an ordinary create. With a token, the first create is recorded against it and a replay carrying the same Fingerprint returns that same row — re-read under a freshly resolved get decision for the replaying caller, never under this create decision — and writes nothing. When no policy allows get at all, the replay is not refused: it answers with the id alone, taken from the recorded key and never from a row read, because a match on the record’s identity already proves this caller created that row. When the re-read does not return the row — the get rule’s own predicate excludes it, or it has been deleted since — the answer is the id alone too, as the first create’s was. The record is scoped to the caller’s tenant and user, and a token from an anonymous caller is refused, because there is no identity to scope it by.
  • cancellationToken — A token to cancel the operation.

Returns: The created row exactly as GetAsync by context would return it — under the get decision’s USING, tenant scope and hidden mask, never the create decision’s — or an AlvoRecord carrying only id when that read would not return it: no policy allows get at all (then no row read is performed), or the get rule’s own predicate excludes the row this caller just wrote. See idempotency and EfAlvoData.EchoedAsync’s remarks for the safety argument.

Task<AlvoBatchResult> CreateManyAsync(string entity, IReadOnlyList<IReadOnlyDictionary<string, object?>> rows, AlvoContext context, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Creates many rows in one transaction.

One transaction: either every row is written or none is. A refusal on the last row undoes the first, and no caller ever observes a half-applied batch — which is what makes the refusal list usable, because a caller repairs the rows it names and resends the whole batch.

Every row is judged individually, against this caller’s WITH CHECK predicate and the synthesized tenant scope, exactly as its single-row sibling would be. “Checks the first row and lets the rest through” is the failure this contract exists to forbid: a batch is not a licence to write rows a single call could not.

A row this caller cannot see and a row that does not exist are one refusal, byte for byte. Distinguishing them would make a batch answer as many existence questions per request as it carries rows — the oracle the single-row AlvoRecordNotFoundException already closes, multiplied by the batch size.

One key for the whole batch. A batch is one request, so a partial retry is not expressible and a per-row key would promise one. The fingerprint covers every row, so the same key with a different list is AlvoIdempotencyConflictException rather than a replay.

  • entity — The entity name.
  • rows — The payloads to create, in the order the caller supplied them.
  • context — The caller performing the writes.
  • idempotency — The caller’s token for the whole batch, or null for an ordinary write. A replay carrying the same Fingerprint answers the recorded rows, re-read under a freshly resolved get decision, without writing again — one entry per recorded row, the id alone for a row that read does not return.
  • cancellationToken — A token to cancel the operation.

Returns: The rows this batch wrote, in request order and one entry per row — each as GetAsync by context would return it, or its id alone when that read would not (see CreateAsync) — or every reason it wrote none.

Task DeleteAsync(string entity, Guid id, AlvoContext context, AlvoPrecondition? precondition = null, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Deletes a row by id.

  • entity — The entity name.
  • id — The row id.
  • context — The caller performing the delete.
  • precondition — The version the caller believes the row holds, or null to delete unconditionally. Compared against the row-locked pre-image inside the delete’s own transaction, under the same ordering rules an update follows.
  • idempotency — The caller’s idempotency token, or null for an ordinary write. With a token, the first write is recorded against it and a replay carrying the same Fingerprint is answered without writing again — by answering that the row is gone without reading anything, because there is nothing left to read. The record is scoped to the caller’s tenant and user, and a token from an anonymous caller is refused.
  • cancellationToken — A token to cancel the operation.
Task<AlvoBatchResult> DeleteManyAsync(string entity, IReadOnlyList<Guid> ids, AlvoContext context, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Deletes many rows by id in one transaction.

One transaction: either every row is written or none is. A refusal on the last row undoes the first, and no caller ever observes a half-applied batch — which is what makes the refusal list usable, because a caller repairs the rows it names and resends the whole batch.

Every row is judged individually, against this caller’s WITH CHECK predicate and the synthesized tenant scope, exactly as its single-row sibling would be. “Checks the first row and lets the rest through” is the failure this contract exists to forbid: a batch is not a licence to write rows a single call could not.

A row this caller cannot see and a row that does not exist are one refusal, byte for byte. Distinguishing them would make a batch answer as many existence questions per request as it carries rows — the oracle the single-row AlvoRecordNotFoundException already closes, multiplied by the batch size.

One key for the whole batch. A batch is one request, so a partial retry is not expressible and a per-row key would promise one. The fingerprint covers every row, so the same key with a different list is AlvoIdempotencyConflictException rather than a replay.

No precondition, and that is a decision rather than an omission. An AlvoPrecondition is one version, and a batch addresses many rows — so the header a single-row delete honours has no meaning here, and accepting one version for a list would either check one row or check none while looking as though it checked all. A caller who needs per-row conditions performs per-row deletes.

A batch that names one row more than once is refused, and an implementor must refuse it. Every row is judged against its own pre-image before any row is written, so two entries for one row are both judged against the original and then both applied — leaving a composition no verdict ever saw, which is a WITH CHECK bypass. See RowNamedTwice for the worked example and for why folding the entries is not the answer.

  • entity — The entity name.
  • ids — The rows to remove, in the order the caller supplied them.
  • context — The caller performing the deletes.
  • idempotency — The caller’s token for the whole batch, or null. A replayed delete reads nothing: its rows are gone by construction, so the record itself is the whole answer.
  • cancellationToken — A token to cancel the operation.

Returns: How many rows were removed, or every reason none were. Rows is always empty — a delete produces none, which is why Affected exists.

Task<AlvoRecord?> GetAsync(string entity, Guid id, AlvoContext context, CancellationToken cancellationToken = default)

Reads a single row by id.

  • entity — The entity name.
  • id — The row id.
  • context — The caller performing the read.
  • cancellationToken — A token to cancel the operation.

Returns: The row, with every hidden field stripped, or null when it does not exist or the caller’s policy excludes it — the two are indistinguishable.

Task<AlvoPage> QueryAsync(AlvoQuery query, AlvoContext context, CancellationToken cancellationToken = default)

Lists an entity’s rows visible to context: every row that satisfies both the resolved policy predicate and query’s own Filter. The caller’s filter can only narrow this result, never widen it past what policy already allows.

A filter or sort key may only name a field the caller can actually read. Filtering, sorting and paging are applied to the stored row while masking is applied to the response, so a filter over a field in HiddenFields would leak that field one comparison per request and a sort over one would leak its ordering across the whole page. An implementation must reject both — masks fail closed, so the query is refused, never answered with the offending term quietly dropped.

A filter or sort key must also name a field the entity’s schema actually declares. This is the one caller-supplied string an implementation interpolates into WHERE/ ORDER BY as an identifier — SQL has no bind-parameter form of a column name — so validating it here, against the schema, is what keeps that interpolation safe; an implementation must not rely on the engine’s own unknown-column error, which happens after the statement is composed. The refusal must be indistinguishable from the hidden-field refusal above and must not echo the offending name (it is attacker-controlled text): a caller must not be able to tell “exists but hidden from you” from “does not exist”.

A filter tree deeper than MaxDepth is refused, not walked. Every backend walks a filter recursively, so an implementation must call EnsureWithinLimits before doing so — the one malformed-argument rejection on this port, deliberately an ArgumentException rather than an authorization failure, because it discloses nothing about the schema or the caller’s access and a caller needs to know their query shape was refused rather than their permissions.

  • query — The entity, filter, sort, and paging to apply.
  • context — The caller performing the query.
  • cancellationToken — A token to cancel the operation.

Returns: One page of every visible, matching row, with every hidden field stripped. NextCursor is an opaque, provider-issued token — only the implementation that issued it may interpret a later After carrying it back, and it is null exactly when this page is the last one the query has. TotalCount is null unless IncludeTotalCount asked for it, and when it did it counts the policy-filtered set narrowed by the caller’s filter — never the table, and never this page: an implementation composes the count over the same WHERE terms as the page and drops the ordering, the window and the cursor boundary.

Task<AlvoReplaceResult> ReplaceAsync(string entity, Guid id, IReadOnlyDictionary<string, object?> values, AlvoContext context, AlvoPrecondition? precondition = null, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Creates or replaces the row id names, writing it whole.

This is the one operation where the caller supplies the row’s key, and only through id.id inside values is refused here exactly as it is on every other route — the store still mints every key it is not handed one for, and CreateAsync and the batch verbs are unchanged.

It replaces; it does not merge. A field values does not mention is written null, not left at its stored value — that is the whole difference from UpdateAsync, which is partial by contract. A body that omits a required field therefore cannot express the row and is refused with ArgumentException naming it; a caller who wants to change one field wants UpdateAsync. Framework-managed columns are exempt: created_at and created_by survive a replacement, because a replaced row is the same row.

Both branches are gated, and the caller needs both permissions. An implementation resolves create and update and refuses unless both allow, so neither branch is reachable by naming an id that happens to fall the other way. The WITH CHECK predicate is evaluated on the candidate row on both branches — an upsert that judges only the branch with a stored row to compare against is a policy bypass on half its inputs.

A row this caller’s USING excludes is not replaced and not overwritten. The pre-image read finds nothing, so the create branch runs and its insert collides with the stored row’s key: the answer is AlvoConstraintViolationException. That the answer differs from a free id’s is a disclosure this operation cannot avoid — a primary key cannot collide silently — and it is narrowed by requiring create as well as update, and by the caller having had to hold the id already.

On a tenant-scoped entity the create branch places the row in the caller’s own tenant, because tenant_id is refused from values on this route whichever branch runs — a branch-dependent answer to “may I write this column” would report whether the row exists. A caller creating into another tenant uses CreateAsync.

  • entity — The entity name.
  • id — The row’s identity: the row to replace, or the id to create it under.
  • values — The whole row, minus the framework-managed columns and any computed field.
  • context — The caller performing the write.
  • precondition — The version the caller believes the row holds, or null to write unconditionally. Compared against the row-locked pre-image under the same ordering an update follows. On the create branch there is no version to match, so a supplied precondition fails: naming a version is asserting the row exists.
  • idempotency — The caller’s idempotency token, or null for an ordinary write. A replay answers Created``false however the first request went, because it reports the state that request left rather than performing an act of creation.
  • cancellationToken — A token to cancel the operation.

Returns: Which branch ran, and the row as GetAsync by context would return it — or its id alone when that read would not, on either branch; see CreateAsync.

Task<AlvoRecord> UpdateAsync(string entity, Guid id, IReadOnlyDictionary<string, object?> values, AlvoContext context, AlvoPrecondition? precondition = null, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Updates a row by id with a partial set of field values.

  • entity — The entity name.
  • id — The row id.
  • values — The field values to change; a field this dictionary does not mention keeps its stored value — WITH CHECK is evaluated over the complete post-image (the stored row merged with these values), never over values alone.
  • context — The caller performing the update.
  • precondition — The version the caller believes the row holds, or null to write unconditionally. Compared against the row-locked pre-image inside the write transaction — see the type remarks for the ordering rules, which are part of the contract.
  • idempotency — The caller’s idempotency token, or null for an ordinary write. With a token, the first write is recorded against it and a replay carrying the same Fingerprint is answered without writing again — by re-reading the recorded row under a freshly resolved get decision, exactly as a replayed create is. The record is scoped to the caller’s tenant and user, and a token from an anonymous caller is refused.
  • cancellationToken — A token to cancel the operation.

Returns: The updated row as GetAsync by context would return it, or its id alone when that read would not — the rule CreateAsync states. An update rule admitting a row the get rule excludes does not make that row readable through this answer.

Task<AlvoBatchResult> UpdateManyAsync(string entity, IReadOnlyList<AlvoRowPatch> rows, AlvoContext context, AlvoIdempotency? idempotency = null, CancellationToken cancellationToken = default)

Updates many rows by id in one transaction, each with its own partial payload.

One transaction: either every row is written or none is. A refusal on the last row undoes the first, and no caller ever observes a half-applied batch — which is what makes the refusal list usable, because a caller repairs the rows it names and resends the whole batch.

Every row is judged individually, against this caller’s WITH CHECK predicate and the synthesized tenant scope, exactly as its single-row sibling would be. “Checks the first row and lets the rest through” is the failure this contract exists to forbid: a batch is not a licence to write rows a single call could not.

A row this caller cannot see and a row that does not exist are one refusal, byte for byte. Distinguishing them would make a batch answer as many existence questions per request as it carries rows — the oracle the single-row AlvoRecordNotFoundException already closes, multiplied by the batch size.

One key for the whole batch. A batch is one request, so a partial retry is not expressible and a per-row key would promise one. The fingerprint covers every row, so the same key with a different list is AlvoIdempotencyConflictException rather than a replay.

A batch that names one row more than once is refused, and an implementor must refuse it. Every row is judged against its own pre-image before any row is written, so two entries for one row are both judged against the original and then both applied — leaving a composition no verdict ever saw, which is a WITH CHECK bypass. See RowNamedTwice for the worked example and for why folding the entries is not the answer.

  • entity — The entity name.
  • rows — The rows to change, in the order the caller supplied them.
  • context — The caller performing the writes.
  • idempotency — The caller’s token for the whole batch, or null. See CreateManyAsync for what a replay answers.
  • cancellationToken — A token to cancel the operation.

Returns: The rows this batch wrote, in request order and one entry per row — each as GetAsync by context would return it, or its id alone when that read would not (see CreateAsync) — or every reason it wrote none.

interface

Publishes a host’s own custom application events onto the same durable queue Alvo’s data events travel on.

Why the guarantee ships before the feature it guards. The refusal costs nothing now and cannot be added later without breaking whichever host is already minting entity.orders.updated by then. A forged data event is not a cosmetic problem: every rule and hook subscribing to the real name would fire on it, carrying a partition key and provenance for a record nobody wrote.

Not transactional with anything. “The event is published in the same transaction as the data change” is a guarantee about a data change, and a custom event has none — this appends with one autocommit statement. A host that needs its own write and its own event to commit together cannot get that here.

Task PublishAsync(string type, string subject, IReadOnlyDictionary<string, object?>? data, AlvoContext context, CancellationToken cancellationToken = default)

Publishes one custom application event.

  • type — The event’s name: two or more dot-separated lower-case segments, e.g. orders.approved. Never in the entity., auth. or storage. namespaces — those are Alvo’s own, and a name in one is refused rather than published.
  • subject — What the event is about, in the host’s own vocabulary (e.g. orders/42). It is also the event’s partition key, because it is the only thing here that identifies the subject two events might share — so per-subject ordering is the only ordering a custom event can be given.
  • data — The event’s payload, or null for an event that carries none. Values must be scalars the envelope can express — String, Boolean, Guid, DateTimeOffset, DateTime, DateOnly, the numeric types, or null. A nested dictionary, an array, a JsonElement or any other type is refused: the envelope is a flat record on the wire, so there is nothing for a nested value to become. Flatten it, or serialize it to a string yourself.
  • context — The caller publishing it, recorded as the event’s provenance. Never ambient.
  • cancellationToken — A token to cancel the operation.

interface

The one operation surface for administering an Alvo project. The admin dashboard resolves it from DI and calls it in-process; an agent, the CLI and a later MCP adapter reach the same members over HTTP. One path, two transports: a write never takes a divergent path depending on its caller, only a different serialisation.

Every member is descriptor-shaped and idempotent, so an MCP adapter is a mapping rather than a translation: there is no HTTP-only affordance an adapter would have to fake. A write carries its own expected revision rather than reading it off a request header, which is what keeps the in-process caller’s semantics identical to the HTTP caller’s.

A write is at-most-once through that revision, and attributable through an optional key. A retried write names the revision it was written against, so the second attempt loses the optimistic-lock race and is refused — nothing has to be stored for that to hold. What the revision cannot do is tell the retrying caller why they were refused: “my own write landed and the response was lost” and “somebody else changed the descriptor” are the same 412 and need opposite recoveries. An IdempotencyKey on the request converts the first of them into a replay carrying the revision the first attempt appended. It is optional, and honoured rather than declared — a request that carries one a deployment cannot record is refused, never quietly served without it.

Data is deliberately absent. Rows are read and written through the Data API under the caller’s own context, so no management privilege over data exists and none has to be audited.

Every member has an HTTP route, and a contract test holds that — the drift this shape risks is an operation reachable in-process and not over the wire. The test reads the live endpoint table, so adding a member here without adding a route fails a build.

Every member gates itself, and holding this reference admits nothing. The implementation reads the caller from IAlvoContextAccessor — the same accessor the Data API publishes into — and raises ManagementForbiddenException unless the level the project’s access block resolves them to reaches the level the operation needs. So an in-process caller that publishes no principal is refused, on reads as well as on writes: it is the anonymous caller, and the anonymous caller reaches no level. A host embedding the dashboard publishes the human it is acting for; “whatever composed this reference already admitted the caller” would admit the process, not the person. Over HTTP the route filter (RequireAlvoManagementAccess) asks the same question earlier — before model binding, so an unadmitted caller costs no descriptor parse — and cannot answer differently, because it reads the same table through the same evaluator. One answer, reachable from two transports, which is what contract 4 asks for.

Task<ManagementApplyResult> ApplyDescriptorAsync(string project, ManagementApplyRequest request, CancellationToken ct = default)

Applies a descriptor — the one write path to a project’s configuration.

request carries its own expected revision rather than reading it off a request header, which is what makes the in-process caller’s semantics identical to the HTTP caller’s. Over HTTP that integer arrives as If-Match.

DryRun plans and reports without writing, and is refused by the same destructive guardrail a real apply is: a preview that reported a plan the apply would then refuse would tell an editor its change is ready when it is not.

A replay reports the revision it replays and an empty plan, because this request performed no migration — the plan the original apply ran is GetRevisionAsync’s business, and re-planning it is impossible from a base that has moved.

  • project — The project name.
  • request — The descriptor, the expected revision, and the allowances.
  • ct — Cancellation token.

Returns: What was applied, what would be, or what a previous identical request already applied.

Task<ManagementExpressionVerdict> CheckExpressionAsync(string project, ManagementExpressionCheck request, CancellationToken ct = default)

Answers what applying would say about one expression — by running the validator apply runs on the descriptor with the candidate spliced in, never a second opinion.

It is not a dry-run apply: that is all-or-nothing, tied to a revision and plans a migration, so it cannot answer per keystroke and one bad expression elsewhere would mask this one. It reads no store, no revision and no runtime — only the descriptor the caller sends. A candidate that does not compile is an answer (a finding), not an exception.

  • project — The project name.
  • request — The descriptor, the slot’s pointer and the candidate expression.
  • ct — Cancellation token.

Returns: The findings at or under the slot.

Task<ManagementCapabilities> GetCapabilitiesAsync(string project, CancellationToken ct = default)

What this build honours, warns about and refuses for this project.

The prose is served verbatim. Every consequence and every fix is the framework’s own sentence, already covered by tests and already asserted against the frozen schema; a client that reworded one would be a third spelling of one truth.

  • project — The project name.
  • ct — Cancellation token.

Returns: The capability report.

Task<ManagementCelFunctions> GetCelFunctionsAsync(string project, CancellationToken ct = default)

Every CEL function a descriptor may call on this instance — the built-ins and the host’s registrations — one entry per overload, with parameters, result, nullability and the profiles each compiles in.

The list is the instance’s: a host function exists only in the host that registered it, so a descriptor that calls one is refused as an unknown function by the standalone image and the CLI. Read it before writing a call.

  • project — The project name.
  • ct — Cancellation token.

Returns: The functions, ordered by name, in an envelope that can grow additively.

Task<ManagementDescriptor> GetDescriptorAsync(string project, CancellationToken ct = default)

The project’s current descriptor, exactly as it was applied, with the revision an apply must echo in If-Match. This is the export — no re-serialisation happens.

  • project — The project name.
  • ct — Cancellation token.

Returns: The stored descriptor text and its revision.

Task<ManagementInfo> GetInfoAsync(CancellationToken ct = default)

Describes this deployment: the build, the mode, the data provider and the startup mode.

  • ct — Cancellation token.

Returns: What this instance is.

Task<ManagementRevisionDetail> GetRevisionAsync(string project, int revision, CancellationToken ct = default)

One historical revision — the export of a past state.

  • project — The project name.
  • revision — The revision number.
  • ct — Cancellation token.

Returns: The revision’s provenance and the descriptor it applied.

Task<SchemaModel> GetSchemaAsync(string project, CancellationToken ct = default)

The resolved schema — what the Data API actually serves for this project.

The descriptor is what the author wrote; this is what survived. Where the two differ is where “declared but not honoured” lives, which is what the capability report enumerates.

  • project — The project name.
  • ct — Cancellation token.

Returns: The applied SchemaModel.

Task<IReadOnlyList<ManagementProject>> ListProjectsAsync(CancellationToken ct = default)

Lists the projects this instance serves.

  • ct — Cancellation token.

Returns: One entry per booted project; in this build, exactly one.

Task<IReadOnlyList<ManagementRevision>> ListRevisionsAsync(string project, CancellationToken ct = default)

The project’s append-only configuration history, oldest revision first.

  • project — The project name.
  • ct — Cancellation token.

Returns: Every appended revision’s provenance, without its descriptor body.

Task<ManagementApplyResult> RollbackAsync(string project, int targetRevision, ManagementRollbackRequest request, CancellationToken ct = default)

Restores a past revision by appending the reverse migration as a new revision. History is never rewritten.

A rollback is an apply of a past descriptor, so it carries the same two allowances an apply does and reports the same result. targetRevision and ExpectedRevision are two numbers with two jobs: the target says what to restore, the expected revision says from where.

The destructive guardrail is the point here, not a formality. A reverse migration drops what the forward one added, so the refusal a caller most often meets on this member is the one that saves data they did not say they could lose.

Restoring a different access block is an authorization change. This member’s own level is Developer, and it re-resolves the requirement to Admin when the target’s block differs from the applied one — the same rule an apply is held to, enforced here rather than at a transport, and for the same reason: access lives inside the descriptor, so a history that ever held a looser block would otherwise be a standing escalation.

  • project — The project name.
  • targetRevision — The revision to restore.
  • request — The expected current revision, the allowances, and the provenance.
  • ct — Cancellation token.

Returns: The appended revision, or — for a dry run — the reverse plan and the base it was planned against.

Task SetAiConnectionAsync(StoredAiConnection connection, CancellationToken ct = default)

Writes the instance’s AI connection, replacing whatever was there.

One secret, replaced whole. The endpoint, the model and the key change together; writing them under three names would leave a window in which a screen reports one and the agent dials another.

  • connection — The endpoint, the model and the credential, as one record.
  • ct — Cancellation token.
Task<ManagementPolicyVerdict> SimulatePolicyAsync(string project, ManagementPolicySimulation simulation, CancellationToken ct = default)

Answers what a named caller may do to an entity — by calling the same IPolicyEngine production calls, never a copy of it.

That is the whole of the implementation: bind a context, call Resolve, render the decision. It is also the only reading under which “answers identically to production” is a property rather than a promise — a second evaluator would agree until the day one of them was edited.

  • project — The project name.
  • simulation — The entity, the operation and the caller to simulate.
  • ct — Cancellation token.

Returns: The verdict and the predicates the engine resolved.

record

Options for controlling migration behavior.

public bool AllowDestructive { get; init; }

Gets a value indicating whether destructive changes are allowed.

public string? Author { get; init; }

Gets who is applying this change (audit provenance carried into the appended DescriptorVersion; null for code-first/system).

public bool DryRun { get; init; }

Gets a value indicating whether this is a dry run (no changes applied).

public string? Reason { get; init; }

Gets an optional human/agent-supplied reason for this change, carried into the appended DescriptorVersion.

class

How a deployment supplies its secrets.

public const string ConfigurationSection = "Alvo:Secrets";

The configuration section this binds from.

public string? EncryptionKeyFile { get; set; }

Gets or sets the path to the file holding the key the database-backed store encrypts with — 32 bytes, base64.

public IDictionary<string, string> Values { get; }

Gets or sets the secrets this deployment supplies through configuration itself, by name.

The GitOps door, and the one every cloud vault already fits through: Key Vault, a K8s secret and a user-secrets file all reach IConfiguration through a provider the host adds, so Alvo needs no adapter of its own for any of them. A name set here wins over the writable store, and a write to a name it carries is refused rather than stored and never read.