hexasync.access.client 2610.2.2
hexasync.access.client
The .NET client for the HexaSync graph authorization engine. Start with the resource-server integration guide for packages, DI, consumer tables/migrations, API ownership, transactional publication and collection listing. The UI member-table convention and HTTP sharing contract are in that same guide. This README supplies the detailed .NET methods and mechanics. The package exposes five related surfaces:
| Surface | Purpose |
|---|---|
GraphAuthorizationClient |
ask the engine a permission question, or count a subject's direct grants |
GraphDefinitionsClient |
read entity types, relations, and permissions |
GraphRegistrationClient |
publish resources, relations, and metadata |
Outbox/ |
publish this service's changes transactionally |
Projection/ |
fetch projections and optionally store/read a local copy |
The four HTTP clients — the first three plus ProjectionReader — share one package-owned composition root.
Outbox and local-projection persistence remain explicit because they add tables and require infrastructure owned
by the consuming service.
Every operational flow below has a local diagram; useful diagrams are enriched when behavior changes, not replaced by prose-only pointers. Mermaid sequences label interactions and credentials, flowcharts label decisions, and the existing ASCII system view remains available. Tables/method examples remain the detailed contract.
Register the HTTP clients
Standard ECDSA service identity
services
.AddHexaSyncAccessClient(options =>
{
options.Timeout = TimeSpan.FromSeconds(30);
})
.UseEcdsaServiceIdentity(configuration);
This chain registers:
GraphAuthorizationClient,GraphDefinitionsClient,GraphRegistrationClient, andProjectionReaderthroughIHttpClientFactory;IProjectionSource = HttpProjectionSourcethroughTryAdd, so an existing local implementation wins;- per-send
Authorization: Bearerattachment for every graph client and the graph-outbox named client; - the standard
hexasync.services.proxyclient-credentials resolver and its gated one-time401refresh/replay.
Required configuration:
| Key | Owner | Used for |
|---|---|---|
SSO_SERVICE_URL |
deployment | both /api/v3/auth/token and graph traffic |
SSO_CLIENT_ID |
consuming service | OAuth client identity |
SSO_CLIENT_SECRET |
secret store | OAuth client credential |
There is deliberately no second graph URL in the standard path. UseEcdsaServiceIdentity takes
ClientCredentialsOptions.SsoUrl as the graph origin and appends /api/v3/ to the existing relative graph
routes. A ServiceUrl value supplied in the options lambda is therefore replaced in standard ECDSA mode.
| Concern | Owner |
|---|---|
| JWT signing and token endpoint | SSO |
token exchange, distributed cache, single-flight refresh, one eligible 401 replay |
hexasync.services.proxy |
graph HttpClient composition and current-token attachment |
hexasync.access.client |
| OAuth credentials, domain policy, persistence, and distributed locks | consuming service |
Timeout defaults to 30 seconds and must be positive. For projections it bounds connection and response
headers; the caller's cancellation token bounds streamed row enumeration. The default primary handlers do not
follow redirects. A blank token fails before transport: the three JSON clients retain their
GraphOriginUnavailableException mapping, while ProjectionReader retains its documented
HttpRequestException contract. No general retry, fallback decision, or authorization cache is added.
Registration is idempotent. It is safe if the host already called AddEcdsaInnerServiceResolver, and repeating
the complete access registration chain does not duplicate token handlers or 401 replay. Standard mode refuses
to replace a deliberately registered non-standard IEcdsaInnerServiceTokenResolver.
Custom authentication or transport
Hosts with local or custom authentication omit UseEcdsaServiceIdentity, set the graph origin explicitly, and
add their own delegating handler:
services.AddTransient<MyGraphAuthenticationHandler>();
services
.AddHexaSyncAccessClient(options =>
{
options.ServiceUrl = "https://graph.internal.example";
options.Timeout = TimeSpan.FromSeconds(15);
})
.AddHttpMessageHandler<MyGraphAuthenticationHandler>();
ServiceUrl must be an absolute HTTP or HTTPS origin with no path, user-info, query, or fragment.
AddHttpMessageHandler<THandler>() applies the handler to all four graph clients and the graph-outbox named
client without exposing internal client names. ConfigurePrimaryHttpMessageHandler(...) is available when a
host or test must replace the terminal transport.
The outbox, in one paragraph
Your service owns some entities — profiles, tenants, devices. The engine needs to know they exist and what
they are called, and it must learn this exactly when your write commits and never otherwise. So you write
a row into graph_outbox inside your own transaction, and a background driver walks that table in transaction
order and delivers each entry to the engine. If your transaction rolls back, the entry was never there. If
delivery fails, the entry is still there.
One cursor, in transaction order. The walk reads every lane in one (xid, id)-ordered pass — not a cursor
per lane — so entries are delivered in the order of their writing transactions' ids, whatever lanes they are on. That is transaction order, not wall-clock commit order:
the two agree for two writes to the same row, which the row lock orders, and that is what makes it safe for the engine's metadata upsert to carry no monotonic guard.
Five lanes ship with the package. What goes through an outbox, what does not, and what a parked entry means for a resource server is the guide's outbox convention:
graph.init— initialise a resource and its required relations viaPOST /api/v3/graph/resources/init.graph.metadata— update an entity's public metadata viaPUT /api/v3/graph/entities/{type}/{id}/metadata.graph.delete— report that an authoritative entity was deleted viaPOST /api/v3/graph/entity-deleted.graph.grant— grant or revoke a relation, selected by the payload operation, viaPOST /api/v3/graph/resources/grantsor/grants/revoke.graph.transfer— transfer a single-valued relation viaPOST /api/v3/graph/resources/transfer.
Wire the outbox — three steps
1. Put the tables in your model
The package owns the shape; you own the migration. There are two model extensions, and you call the one(s) whose feature you use:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyGraphOutboxModel(); // graph_outbox, graph_outbox_state, graph_outbox_dead
modelBuilder.ApplyProjectionModel(); // projection_header, projection_detail -- only for local persistence
}
| Extension | Enable when | Tables |
|---|---|---|
ApplyGraphOutboxModel() |
You publish graph changes through the driver in step 2 | graph_outbox, graph_outbox_state, graph_outbox_dead |
ApplyProjectionModel() |
You persist/read projections locally through writer, local reader, Ensure or sweep | projection_header, projection_detail |
They are independent. Remote-only ProjectionReader fetches need no local tables. Leave out an extension you
do not use, and keep the package model configurations in your consumer's migration/snapshot.
⚠️ ApplyProjectionModel creates the two tables UNLOGGED, and that is deliberate. A projection is a cache of
someone else's authoritative data — losing it on an unclean shutdown means re-fetching, not losing anything — and
unlogged tables skip the WAL for a table that is rewritten wholesale on every refresh. It also means they are
empty after a crash restart, so nothing may treat their contents as durable.
Then scaffold your own migration as you would for any other change:
dotnet ef migrations add GraphOutbox --project <your-ef-project>
Why not a DDL script the package hands you? It used to be exactly that, and it rotted in a way worth understanding before you reach for the same shortcut elsewhere.
CREATE TABLE IF NOT EXISTSdoes nothing to a database that already has the table, so every column added after the first release had to arrive as an appendedALTER TABLE … ADD COLUMN IF NOT EXISTS. That list only grows, and it is applied by whatever you remember to run. The failure that actually happened is worse than untidy: a consumer ran the script as raw SQL from inside a migration, so the tables existed in its database and nothing at all was in its model snapshot — and the nextmigrations addcould not see them.An
IEntityTypeConfigurationdescribes the shape as it is, not as a history of edits. Yourmigrations addcomputes the diff, and your snapshot carries the tables, so the migration after this one is computed against the truth.
⚠️ ApplyConfigurationsFromAssembly on your own assembly will not find these — they live in this one.
That is why the call is explicit.
2. Register the driver
services.AddGraphOutbox(o =>
{
o.DeliveryUrl = config["SSO_SERVICE_URL"]; // SSO origin, not a URL ending in /api/v3
o.NotificationUrl = config["ALERT_WEBHOOK_URL"]; // optional today
o.Notify = (notice, ct) => alerts.SendAsync(notice, ct); // required, see below
});
Two things must already be in your DI container, because both are deployment-wide decisions and neither is the package's to make:
NpgsqlDataSource— the outbox tables live in your database. That is the entire point of an outbox.IDistributedLockProvider— the leader lock. Its backend is yours to choose; this workspace uses Redis via Medallion.
AddGraphOutbox registers the driver, all five standard processors, the named HttpClient, and the boot-time
URL check. It resolves nothing at registration time.
Use the same consumer database/schema as your mutation context. The raw package SQL uses unqualified table
names, so a custom schema/search path must agree with EF mappings. alerts.SendAsync is the consumer's real
operator-notice channel, not a no-op/log-only replacement. The standard service identity also authenticates the
outbox named client; registration alone does not apply a migration.
The previous setup and projection prose is archived verbatim. Essential C#/SQL recipes now also appear in the integration guide; this README remains the method reference.
3. Enqueue inside your own transaction
When the standard registration chain is present, no outbox-specific authentication registration is needed:
UseEcdsaServiceIdentity attaches the same per-send identity to GraphOutboxHttp.ClientName. It works whether
AddGraphOutbox runs before or after AddHexaSyncAccessClient.
Custom-authentication hosts use the builder's AddHttpMessageHandler<THandler>() shown above. AddGraphOutbox
by itself does not invent a credential or authentication scheme.
Required unit-of-work boundary: the consumer's mutation-owning method — the application or repository method that begins and commits the transaction — must execute the local database mutation first, enqueue on that exact
NpgsqlConnectionandNpgsqlTransactionsecond, and then commit both together. With EF Core, changing a tracked entity is not the mutation:SaveChangesAsyncmust complete before the enqueue. An independent producer that opens its own connection or transaction, or an enqueue issued before the databaseUPDATE, breaks the outbox's atomicity or ordering guarantee.GraphOutboxWritercannot detect either mistake because it does not know which consumer row the event represents.
await using var tx = await connection.BeginTransactionAsync(ct);
// … your mutation, FIRST …
await UpdateTheProfileRow(connection, tx, profile, ct);
// … then the enqueue, on the SAME connection and transaction …
await GraphOutboxWriter.EnqueueAsync(
connection,
tx,
Guid.CreateVersion7(),
new GraphInitOutboxPayload(
"profile",
profile.Id.ToString("D"),
[new GraphInitOutboxRelation("parent", "user", ownerId.ToString("D"))],
ownerId),
ct);
await tx.CommitAsync(CancellationToken.None);
🔴 Enqueue AFTER the domain mutation/row lock. The scanner walks one global (xid, id) order, not arbitrary
transactions' wall-clock commit order. The consumer's same-resource mutation must complete before its enqueue.
Inside one transaction, event IDs determine order; separate Guid.CreateVersion7() calls need not be monotonic.
The guide has complete create/delete/EF and conditional legacy dual-write recipes, including explicitly ordered init/metadata IDs.
A pre-commit rollback removes both writes. Commit uses CancellationToken.None; a transport/timeout error during
commit is indeterminate, not proof of either success or clean rollback. A local commit is durable publication
intent, not effective IAM access. The earlier ordering paragraph is
archived verbatim.
🔴 The id is yours to supply — normally Guid.CreateVersion7(). There is no column default, because
uuidv7() is a PostgreSQL 18 built-in and this package does not control your server version.
The payload type supplies both its lane and its source-generated JSON contract. There is no overload accepting a
lane string or raw JSON, so a caller cannot pair an init payload with another processor. The lane is stored only in
graph_outbox.kind; a payload contract exposing a JSON property named lane is rejected before the insert.
The package-owned payload contracts are:
| Payload type | Lane | Stored camel-case members |
|---|---|---|
GraphInitOutboxPayload |
graph.init |
resourceType, resourceId, relations, requestedBy |
GraphMetadataOutboxPayload |
graph.metadata |
entityType, entityId, values; the processor sends only values as the HTTP body |
GraphDeleteOutboxPayload |
graph.delete |
entityType, entityId, acknowledgeMassRevocation |
GraphGrantOutboxPayload |
graph.grant |
operation (grant or revoke), resourceType, resourceId, relation, subjectType, subjectId, grantorUserId, hint, expiresAt |
GraphTransferOutboxPayload |
graph.transfer |
resourceType, resourceId, relation, fromType, fromId, destinationType, destinationId, requestedBy, acceptedBy |
Nullable members are serialized as JSON null; they are not omitted.
The shape of the running system
your handler ──┬── domain write / EF SaveChanges ┐
└── INSERT typed graph_outbox ┘ same connection + transaction
COMMIT -> durable intent, not effective access
▼
GraphOutboxService (one per pod; exactly one holds `graph-outbox-driver`)
│
└─ ScanOnceAsync(LIMIT 100) ONE cursor, every lane, in (xid, id) transaction order
│
│ the batch is cut into CONTIGUOUS RUNS of one lane
├─ run of 12 × graph.init ─────→ GraphOutboxInitProcessor ──────→ engine
├─ run of 3 × graph.metadata ─→ GraphOutboxMetadataProcessor ──→ engine
├─ run of 2 × graph.grant ────→ GraphOutboxGrantProcessor ─────→ engine
└─ run of 1 × graph.delete ───→ GraphOutboxDeleteProcessor ────→ engine
GraphOutboxRetentionService (one per pod; exactly one holds `…-retention`)
│
└─ every RetentionInterval: two bounded DELETEs, at or BELOW the cursor
Delivery ordering and acknowledgement:
Independent retention:
🟢 The two services are independent, and a DIFFERENT pod may hold each lease. Nothing coordinates them and nothing needs to: retention only ever touches rows at or below the cursor, and the walk only ever reads rows above it, so the two sets are disjoint by construction.
One driver, ONE cursor, every lane. The lanes are the registered processors, not a
SELECT DISTINCT(lane). A pass is one bounded SELECT over the whole table in (xid, id) order — BatchSize
entries whatever happens inside it — cut into runs of consecutive same-lane entries and handed to each lane's
processor in turn.
🔴 Isolation between lanes is GONE since
1.16, and that is the trade. A retryable failure now blocks everything behind it rather than one lane — a401retries forever, so it stalls the whole outbox. Permanent failures still park and advance, so the exposure is narrower than it first looks. This was chosen because the alternative is worse and silent: per-lane cursors preserve commit order inside a lane and discard it between lanes, so once a delete lane exists a delete can overtake a metadata write and the row it deleted comes back. Nothing errors and nothing logs. A blocked queue is visible delay; resurrection is silent corruption.
One leader. Every pod starts the driver; one acquires the lock and works, the rest stand by and take over on eviction. Standby is the normal state, not an error. If the leader loses its lease mid-pass it stops immediately — a holder that kept working after eviction would produce the exact double-driver state the lock exists to prevent.
🔴 THE LOCK IS THE SINGLE-WRITER GUARANTEE, and YOU own the lease TTL. This paragraph used to say the opposite — that the lock bought "waste, not correctness" and that a conditional claim in SQL was what stopped two pods notifying. That second guard was removed by owner ruling on 2026-09-03: only the driver calls the notifier, it runs under the lock, and a lease long enough to outlast any real pause makes a second concurrent caller a case not worth carrying code for.
What that puts on you, because this package cannot do it: it takes
DistributedLock.Core— the abstraction only — so the expiry is set where you construct your provider. Configure a lease that comfortably outlasts your worst pause, and remember it is the same knob as failover: a leader that is OOM-killed leaves the outbox undrained until its lease expires.🟢 Still true and still load-bearing: the walk can deliver an entry twice, and four standard lanes' destinations absorb a second delivery.
graph.transfer's does not: a replay of a transfer that already applied parks as409 TRANSFER_FROM_MISMATCH, although the data is correct (per lane). Do not rule out a second delivery on the strength of the lock.
Adding your own lane
Define a typed payload, implement the typed processor interface, and register the processor:
public sealed record DeviceProjectionPayload(string DeviceId)
: IGraphOutboxPayload<DeviceProjectionPayload>
{
public static string Lane => "myservice.devices";
public static JsonTypeInfo<DeviceProjectionPayload> JsonTypeInfo
=> DeviceOutboxJsonContext.Default.DeviceProjectionPayload;
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DeviceProjectionPayload))]
internal sealed partial class DeviceOutboxJsonContext : JsonSerializerContext;
await GraphOutboxWriter.EnqueueAsync(
connection,
tx,
Guid.CreateVersion7(),
new DeviceProjectionPayload(deviceId),
ct);
public sealed class DeviceProjectionProcessor
: IGraphOutboxLaneProcessor<DeviceProjectionPayload>
{
public async Task<IReadOnlyList<DeliveryOutcome>> DeliverAsync(
IReadOnlyList<GraphOutboxEntry> run, CancellationToken ct)
{
var outcomes = new DeliveryOutcome[run.Count];
for (var i = 0; i < run.Count; i++) // IN ORDER. One at a time.
{
var response = await _client.PostAsync("…", Body(run[i].Payload), ct);
outcomes[i] = DeliveryOutcome.FromStatus((int)response.StatusCode);
if (!outcomes[i].IsDelivered && !outcomes[i].ShouldPark)
{
// not attempted -- nothing was decided about these, so they are retries
for (var rest = i + 1; rest < run.Count; rest++)
outcomes[rest] = DeliveryOutcome.Retry("not attempted");
break;
}
}
return outcomes;
}
}
services.AddScoped<IGraphOutboxLaneProcessor, DeviceProjectionProcessor>();
🔴 DELIVER THE RUN IN ORDER, ONE AT A TIME. This is the one rule the batch shape hands to you, and nothing in
the type system enforces it. await Task.WhenAll(run.Select(Send)) looks like an obvious throughput win and
it reproduces, inside a single run, the out-of-order delivery the single cursor exists to remove. If a run ever
has to be pipelined, the partition must be the entity, not the operation.
🔴 Exactly one outcome per entry, in the same order. An entry you chose not to attempt is a Retry — that
is what not attempted means. A count that does not match the run fails the whole run and is logged as a
processor defect; it is never read as "the rest succeeded", which would advance the cursor past entries nobody
classified.
🟢 Why a list and not one status for the run. One status cannot say "9 delivered, 1 parked, 2 not attempted" — and being able to say that is what keeps stop at the first failure, never skip and who advances the cursor inside the scanner, where they are written once, instead of in every processor you ever write.
Return, don't throw, for an expected refusal. A throw is caught and treated as a retry, which is the right default for a broken transport — it keeps deliverable data out of the dead-letter table.
What happens when delivery fails
| Response | What the driver does |
|---|---|
| 2xx | delivered; the cursor advances past it |
permanent (the 400/409 allowlist), or an entry the processor cannot send (parked as 400 without a call) |
the entry moves to graph_outbox_dead and the whole outbox continues, every lane |
| anything else | retried forever, and the whole outbox — every lane — does not advance past it |
There is no attempt limit, and that is the design: what matters is whether the response can ever change, not
how many times you have asked. Nor is there a backoff: the driver does not read Retry-After, so a 429 or a 503 is retried after IdleDelay (about every 5 s) and holds up every entry behind it.
A parked entry is logged at Warning in the pass that parks it, and raises a parked notice through your Notify delegate, at most one per 15 minutes. That
delegate is required for a reason — an earlier version halted the lane on a permanent failure, which was
impossible to ignore; parking is easy to ignore, and a notice nobody configured is the same silence in a
different place.
Triage and replay through GraphOutboxDeadLetterStore — the list route deliberately carries no payloads, and a
requeue inserts a new entry with a fresh id so both attempts stay traceable. Mount its routes with MapGraphOutboxDeadLetter(prefix, readPolicy, requeuePolicy); what a parked entry means, lane by lane, is the guide's outbox convention.
What to alert on
The stuck notice, and nothing else. first_stuck_at is stamped when a round ends in a retry and cleared by
any round that delivered or found nothing, so it reports how long the outbox has been failing and an idle
outbox cannot raise it. It reaches you through your Notify delegate after StuckAfter (2 min), throttled to
one per 15 minutes, carrying the last error and the failing entry's lane — the state is global since 1.16,
so the lane travels in the notice rather than on the cursor.
🔴 Do not build a lag metric from the walk's cursor. The package shipped one and removed it in story
1.15, because it is wrong in both directions. An idle lane's cursor is frozen while the horizon advances with every unrelated transaction in your database, so the number climbs forever on a healthy system. And when the horizon genuinely is pinned by a long-running query, the lane drains everything below it and the number falls to ~0 — it reads healthiest exactly when the problem is real. A pinned horizon is a database condition with its own monitoring; this package watches the queue.
The walk still cannot advance past the oldest in-flight transaction anywhere in your database — that cost is real, it is simply not observable from here.
Reading projections locally
Projections are cached listing candidates for (subject_type, subject_id, resource_type), not local
authorization decisions. Filter a required permission bit, join the canonical IAM resource reference to the
consumer's domain table, apply domain scope/search/keyset paging locally, and use bounded origin checks for
protected metadata and exact operations. The
integration guide supplies the coherent setup/C#/SQL path.
The earlier local-permission/exactly-one-active prose is
archived verbatim.
Fetch, store, ensure and read
Remote-only fetch — consume and dispose, without persistence:
Ensure — warm candidates or a complete cold rebuild:
Writer refresh — only complete persisted generations become active:
Local read — no fetch or implicit refresh:
| API | Contract |
|---|---|
ProjectionReader.OpenAsync(resourceType, subjectType, subjectId, ct) |
Remote response/stream; consume to completion and dispose. This does not create consumer tables or persist rows. |
IProjectionSource.OpenAsync(resourceType, subjectType, subjectId, ct) |
Fetch abstraction; standard HTTP registration supplies HttpProjectionSource |
ProjectionWriter.RefreshAsync(connection, source, resourceType, subjectType, subjectId, ct) |
Writes an inactive generation, flips only after complete ingestion; refusals create none, truncation cannot activate partial contents |
ProjectionEnsure.EnsureAsync(connection, source, locks, resourceType, subjectType, subjectId, now, cancellationToken, queue, logger) |
Probe existence; serve a warm generation at any age, enqueue optional stale refresh, otherwise wait/rebuild under a real subject/type lock |
ProjectionLocalReader.OpenAsync(connection, subjectType, subjectId, resourceType, now, ct) |
Returns ProjectionLookup: Freshness, ProjectionAt, ConfigVersion, and ReadRowsAsync(ct). It reports Absent/Stale/Fresh and does not refresh. |
Check Ensure's HasActiveGeneration; cold failure/refusal is not an empty allowed set. Rebuilt does not identify
a frozen generation. A writer's superseded result is not a promise that its returned GenerationId is active.
An active empty generation is legitimate; Absent means rebuild. Keep the open connection alive for the local
row stream and dispose any active reader before issuing another command.
Tables and mask interpretation
| Table | Fields / invariants |
|---|---|
projection_header |
id, subject_type, subject_id, resource_type, projection_at, nullable config_version, active; at most one, possibly zero, active generation under the full subject/type scope |
projection_detail |
id, projection_id, resource_id, bitmask as signed bigint; join through the active header |
ApplyProjectionModel() adds both package configurations to the consumer's model/migration. The
proj_hdr_active_uq partial unique index is filtered on active; inactive builds coexist with the current one.
Both tables are UNLOGGED and may be empty after crash recovery. They do not create tenant/workspace isolation.
GraphDefinitionsClient.GetPermissionBitsAsync(resourceType) returns per-type (Permission, BitPosition)
entries; use these permission positions, never relation positions or assumed shared masks. Bit 63 gives a
negative signed flag: test (bitmask & flag) != 0, not > 0. Refused mapping/missing permission cannot bypass
filtering. The per-type HTTP read permits an attributable service with access:graph:read_bit_mapping,
without an entity-authority row; the separate owned-types enumeration still filters by authority.
The mapping exposes no version field and the client does not automatically compare it with the generation's
config_version or a check result.
Warm refresh is not maintenance
Warm refresh worker:
Maintenance and cache loss:
Register a ProjectionRefreshQueue and ProjectionRefreshWorker explicitly if Ensure should enqueue warm
refreshes. The worker options need its data source, scope factory, real lock provider and the same queue;
it obtains a fresh scope/connection and optionally uses ProjectionRefreshThrottle. Do not borrow a request
reader/source/connection. HTTP registration alone does not start this persistence/refresh path.
Five minutes (ProjectionReader.ClientProjectionTtl) triggers optional refresh; it is not a serving-age bound
or authorization SLA. A failed warm refresh leaves the previous active generation usable as candidates.
ProjectionSweepService is separately registered with ProjectionSweepOptions { DataSource = dataSource }.
It reaps inactive generations older than 30 minutes and deactivates active ones older than 24 hours.
It never fetches/refreshes IAM, does not recompute grant expiry, and has no leader lock. Registering it cannot
guarantee bounded stale disclosure.
Candidate queries page in the consumer database; do not materialize an unbounded allowed-ID list.
Refresh can change candidates between requests; a domain cursor is not a durable generation handle or access
credential. Protected list data uses bounded CheckBulkAsync and actual operations use resource-bound
CheckAsync plus consumer policy/capabilities. Dependency failures remain unavailable, not fabricated denies
or empty lists. Neither check API offers a caller-selectable Live mode.
Options
| default | ||
|---|---|---|
DeliveryUrl |
— | required; the engine's base address, checked at boot |
NotificationUrl |
— | where notices go; optional today |
Notify |
— | required; the channel is yours |
BatchSize |
100 |
entries per pass, across every lane |
IdleDelay |
5s |
after a pass that delivered nothing |
ErrorDelay |
30s |
after a pass that threw — a different thing from finding nothing |
LeaderLockName |
graph-outbox-driver |
deployment-scoped, not per lane |
LeaderWait |
30s |
how long one acquisition attempt blocks; standby retries forever |
UseStandardProcessors |
true |
set false only to substitute your own; an extra lane is added, not swapped |
RetentionWindow |
7d |
how long a delivered entry stays readable before it is swept |
RetentionInterval |
1h |
how often the retention loop sweeps |
RetentionBatchSize |
1000 |
rows removed per pass per table, so a first run against a backlog stays bounded |
RetentionDisabled |
false |
when true the retention service is not registered at all |
RetentionLockName |
{LeaderLockName}-retention |
its own lease — see below for why it must not be the driver's |
🔴 Retention runs in its own hosted service with its own leader lock, registered for you by AddGraphOutbox.
Two things about that are worth knowing before you change either name:
- It must not share
LeaderLockName. The driver holds that lease continuously while it is the leader, so a second service waiting on it would acquire only when the driver stopped — retention would simply never run, with no error and no log. - The default is derived rather than constant. Two applications sharing one lock backend must not fight; if you
renamed
LeaderLockNameto avoid exactly that, you get a distinct retention lock for free. A fixed literal would have given you one retention leader globally and left the second application's table unswept, silently.
Why a lock here at all, when ProjectionSweepService deliberately has none? Because this sweep is bounded.
The projection sweep is unbounded and convergent — running it N times equals running it once, so a lock would buy
only saved round trips. Retention removes at most RetentionBatchSize per pass, so N unsynchronised pods remove up
to N× the ceiling that option promises — and that ceiling exists for the first run against a backlog, which is
exactly when it matters.
Upgrading to 1.26 — two breaking changes
QueryResourceGrantsAsyncreturns the grouped shape, andGraphGrantRowis gone. It returned one flat row per tuple, and the server stopped sending that shape in invitation-unification1.29— so every row carriedRelation = nullandExpiresAt = null, and nothing failed. It now returnsGraphReadResult<GraphGrantsPage>: one row per subject with the relations inside.// before IReadOnlyList<GraphGrantRow> rows = await registration.QueryResourceGrantsAsync(type, [id]); var grants = rows.Select(r => (r.SubjectId, r.Relation, r.ExpiresAt)); // Relation was always null // after var result = await registration.QueryResourceGrantsAsync(type, [id], pageSize: 200); if (!result.IsSuccess) throw new MyGraphException(result.Code); // e.g. NOT_TYPE_AUTHORITY var grants = result.Value!.Grants.SelectMany(row => row.Relations.Select(h => (row.SubjectId, h.Relation, h.ExpiresAt))); // more than one page: pass result.Value.NextCursor back as `cursor`, same filters, until it is null- Paging is yours. It read only the first page — 50 subjects — and said nothing. Follow
NextCursor. - A
403or a429is now a value, not an exception. A caller that caughtGraphOriginUnavailableExceptionfor either must checkIsSuccess. A read carries noRetryAfter. Codeis the server's, unlike this package's other reads, soIsForbiddenisfalseforNOT_TYPE_AUTHORITY: a missing capability and a missing entity-authority registration are fixed in different places.- It requires an sso.api that returns
labels(iam2.118). A body withoutgrants,labelsor a row'srelationsthrows rather than reading as empty.
- Paging is yours. It read only the first page — 50 subjects — and said nothing. Follow
GrantAsyncgainsDateTimeOffset? expiresAt, afterhint. That is a binary break for every compiled caller, and a source break for a call that passes the cancellation token by position. 🔴 An expiry, and a hint, take effect only when the grant creates the tuple: granting a relation the subject already holds keeps the existing expiry and hint, and answers"granted"all the same. A relation that forbids an expiry answers409 EXPIRY_NOT_ADMITTED_ON_RELATION, as aRefusedresult.
Additive: GraphWriteResult.Grant carries DisplacedResourceId — the other resource a granted hint moved off,
because a hint is unique per subject and resource type. null Grant means the answer is unknown; a Grant with a
null DisplacedResourceId means nothing moved. outcome is not exposed: it is fixed by the route.
Upgrading to 1.25 — one breaking change, one new status
1. InviteToGrantAsync no longer takes lifetime. 🔴 A caller that passed one stops compiling, which is the
intent: the server removed the field on 2026-09-04 and ignored it from then on, so that caller believed its
invitation expired when it did not. Delete the argument. The window is sso.api's InvitationSettings, chosen per
relation — there is nothing to pass instead. A caller that never passed it only needs to recompile: the
parameter was part of the compiled signature, so a binary built against the old package fails with
MissingMethodException until it is rebuilt.
2. GraphWriteStatus.Throttled is new, for a 429. Before, a 429 came back as Refused — indistinguishable
from a permanent answer — and the server's Retry-After was dropped. It now carries
GraphWriteResult.RetryAfter. IsSuccess is unchanged (false). A caller that gave up on every Refused was
wrong to for a 429 and is no longer asked to. 🟠 A switch expression over the status with no discard arm
now throws SwitchExpressionException on the new value — add the arm. The member is appended last, so
Refused keeps its numeric value.
3. InviteTransferAsync's email form now works. It used to send subjectType: "email" with the address as
subjectId, which the server refused on every call; it now sends the address form. No action needed.
Upgrading to the typed outbox writer
GraphOutboxWriter now exposes only EnqueueAsync<TPayload>. The overload taking separate kind and
payloadJson strings is removed with no compatibility shim. Replace anonymous/raw JSON producers with one of the
five standard payload records, or implement IGraphOutboxPayload<TPayload> for a custom lane. A custom payload
must supply source-generated JsonTypeInfo<TPayload> and must not serialize a root lane property or use JSON
extension data.
Processors for custom lanes implement IGraphOutboxLaneProcessor<TPayload> so enqueue and delivery obtain the
lane from the same payload type. The caller still supplies the event ID, mutation connection, and transaction.
Upgrading to 1.18 + 1.20 — one migration, three breaking changes
These two ship together. Scaffold ONE migration after taking the package, not two:
dotnet ef migrations add GraphOutboxState --project <your-ef-project>
1. graph_outbox_cursor is DROPPED and replaced by graph_outbox_state. It was one row of typed columns; it
is now (key text primary key, value text null). The nine facts keep their names as keys — last_scanned_xid,
last_scanned_id, last_scan_at, failed_attempts, last_error, last_dead_letter_at,
last_dead_letter_notification_at, first_stuck_at, last_stuck_notification_at.
Why. Every fact this package tracked about itself was a column, so adding one cost you a migration — three releases in a row did exactly that, and a fourth wanted a housekeeping timestamp and declined to add it rather than make you pay again. A new fact is now an
INSERTby the package and nothing at all by you. It also retires a column namedlanethat had held the constant*since1.16and had not meant a lane since.
🔴 Your development database re-delivers everything once, for the same reason and with the same consequences as
the 1.16 note below: the new table starts empty and the walk starts at zero. Harmless — the destination is
idempotent — but do not be surprised by it. There is no data migration; nothing carries the old row forward.
🟠 If you have not yet scaffolded 1.16's index re-cut, both land in this one migration. Then 1.16's
checklist item — "a DropIndex and a CreateIndex, and nothing else" — no longer holds: you will also see the
cursor table dropped and the state table created. That is correct; do not reject it.
2. GraphOutboxSchema is GONE. Both of its public consts go with it — CursorLane (there is no keyed cursor
row to name) and SeedCursorSql (there is no seed: reads coalesce an absent key to its default and every write is
an upsert). If you referenced either, GraphOutboxState's key constants are the replacement.
3. GET …/lanes always answers 200; the 404 arm is deleted. The endpoint's result type changes from
Results<Ok<GraphOutboxHealth>, NotFound> to Ok<GraphOutboxHealth> — source-breaking if you matched on the union.
The response body is unchanged. 404 used to mean "nothing has ever scanned", which was only ever safe
because the old cursor row was seeded on every pass; without a seed a healthy-but-idle outbox writes nothing at
all, so keeping the arm would have answered 404 for ever on a perfectly working deployment.
🟢 Not breaking, but new: retention moved out of the delivery pass into its own hosted service —
AddGraphOutbox registers it, so there is nothing for you to wire. See Options for its lock.
Upgrading to 1.16 — three breaking changes
1. IGraphOutboxLaneProcessor.DeliverAsync takes a RUN and returns one outcome per entry. Every processor
stops compiling, which is the intent — see Adding your own lane above for the shape, and the ordering rule that
comes with it.
2. GET …/lanes answers with ONE object, and ALWAYS 200. The scan and notification stamps live in
graph_outbox_state and cover every lane. A client reading .lanes[0] gets nothing rather than an error. 🔴 Since
1.18 the 404 arm is gone: package state has no seeded row, so a healthy-but-idle outbox has simply written
nothing and every stamp is null. The response record is unchanged; only the endpoint's result type is.
3. A LOCAL database re-delivers everything once. The cursor is keyed on a constant instead of a lane name, so an existing development database keeps its old per-lane rows — nothing reads them — and the new row starts at zero. Harmless: the destination is idempotent and re-delivery is the failure mode this design is built for. There is no data migration and no production deployment holds cursor rows.
🟠 The migration is a schema change only, and it is the usual one: migrations add from the model. It drops
ix_graph_outbox_walk and recreates it on (xid, id). Do not skip it — the walk no longer filters on kind,
so against the old (kind, xid, id) index the keyset has an unbound leading column and every pass degrades to a
sequential scan and a sort of the whole table. Probed against postgres:18, 60 000 rows.
Two failure modes worth recognising
"Everything is healthy and the graph is stale." The driver catches every exception a pass throws, logs it, and sleeps. So a misconfiguration produced an outbox that looked alive and delivered nothing — which is why the URLs are checked at boot and the host refuses to start without them.
An entry whose lane has no registered processor is PARKED, immediately. It cannot become deliverable by
retrying — only by a deployment — and under one cursor a retry would block every entry behind it. The payload
survives in graph_outbox_dead and POST /{id}/requeue replays it once the processor ships. 🟠 So a producer
deployed before its processor sends its whole stream to the dead table, and requeue is one row at a time: a
bulk route is deliberately not in the package, because the recovery scale is bounded by how long a producer runs
without its processor. The driver refuses to scan at all while no processors are registered, which is what
keeps that case recoverable.
"Registered and idle" looks exactly like "not registered at all." A consumer enqueued onto both lanes for a
month while nothing drained them, because wiring the outbox took six registrations per lane and nobody did it.
That is what AddGraphOutbox exists to prevent — and why the driver logs a Warning, every pass, when no
processors are registered.
Inviting a team to your resource
🔴 The contract is in the Resource sharing guide: who sends each call and with which token, every route's capability, request, response, codes, error envelope and rate limit, the rules for your BFF, the gates your backend owns, and the deployment prerequisites — stated once, for every consumer. This chapter is the .NET half: which member of this package sends which route, and how to handle what it returns. (2026-09-30, sso.api story
2.121: the contract this chapter used to state moved to the guide, and each removed block left a marker naming where it went. The text as it stood is archived.)
↪ Moved to the guide: how a team is named (the address link) and the flow end to end → Inviting a team from outside the scope, Team address links; who sends each call, with which credential, and the rules for your BFF (bearer header, token handling, CORS,
Retry-After, polling) → Transport; resend's cooldown and a retried cancel → Resend, Cancel; the error envelopes → Error envelopes; cancel and resend from each list row → Cancel and resend.
Which member sends each route
Every route in the guide, with what sends it. Each row links the route's contract.
User calls: sent by your BFF with the user's token. This package authenticates as your service, so it has no method that can send them, by design; the members that try are listed below.
| Route | In this package | Contract |
|---|---|---|
POST /api/v3/graph/resources/grants/query |
user call: sent by your BFF with the user's token. QueryResourceGrantsAsync sends the same route on your service token — see the next table |
The grants query |
POST /api/v3/graph/standing |
user call: sent by your BFF with the user's token. ReadStandingAsync sends the same route on your service token — see the next table |
Standing: the caller's own |
GET /api/v3/graph/resources/grants/invitations |
user call: sent by your BFF with the user's token | The invitation list |
GET /api/v3/graph/resources/grants/invitations/pending |
user call: sent by your BFF with the user's token. GetPendingGrantInvitationsAsync cannot succeed |
Inbox |
POST /api/v3/graph/resources/grants/invitations/{invitationId}/accept |
user call: sent by your BFF with the user's token. AcceptGrantInvitationAsync cannot succeed |
Accept |
POST /api/v3/graph/resources/grants/invitations/{invitationId}/decline |
user call: sent by your BFF with the user's token | Decline |
POST /api/v3/graph/resources/grants/invitations/{invitationId}/cancel |
user call: sent by your BFF with the user's token. CancelGrantInvitationAsync is [Obsolete]; AnswerTransferInvitationAsync(…, TransferAnswer.Cancel) cannot succeed |
Cancel |
POST /api/v3/graph/resources/grants/invitations/{invitationId}/resend |
user call: sent by your BFF with the user's token | Resend |
POST /api/v3/identity/workspaces/{workspaceId}/invitations |
user call: sent by your BFF with the user's token | The workspace invite |
POST /api/v3/identity/teams/{teamId}/invitations |
user call: sent by your BFF with the user's token | The team invite |
POST /api/v3/identity/teams/{teamId}/address-links |
user call: sent by your BFF with the user's token | Mint |
GET /api/v3/identity/teams/{teamId}/address-links |
user call: sent by your BFF with the user's token | List |
DELETE /api/v3/identity/teams/{teamId}/address-links/{linkId} |
user call: sent by your BFF with the user's token | Revoke |
POST /api/v3/identity/teams/address-links/resolve |
user call: sent by your BFF with the user's token | Resolve |
POST /api/v3/graph/resources/relations |
user call: sent by your BFF with the user's token | Relations (Manage) |
POST /api/v3/graph/resources/relations/leave |
user call: sent by your BFF with the user's token | Leave |
POST /api/v3/graph/resources/relations/revoke-all |
user call: sent by your BFF with the user's token | Revoke-all (Remove) |
POST /api/v3/graph/resources/relations/revoke-all/impact |
user call: sent by your BFF with the user's token | Impact |
Service calls: sent by your backend through this package, on its service token. Where a write goes through the outbox instead, the lane is named.
| Route | Client member | Contract |
|---|---|---|
POST /api/v3/graph/resources/grants/invitations |
GraphRegistrationClient.InviteToGrantAsync, InviteTransferAsync, InviteTeamByAddressAsync |
Issue, What issue answers |
POST /api/v3/graph/resources/grants |
GraphRegistrationClient.GrantAsync; the outbox lane graph.grant (operation: grant) |
Direct grant |
POST /api/v3/graph/resources/grants/revoke |
GraphRegistrationClient.RevokeAsync; the outbox lane graph.grant (operation: revoke) |
Direct revoke |
POST /api/v3/graph/resources/transfer |
GraphRegistrationClient.TransferAsync; the outbox lane graph.transfer |
Direct transfer |
POST /api/v3/graph/resources/grants/query |
GraphRegistrationClient.QueryResourceGrantsAsync |
The grants query |
POST /api/v3/graph/standing |
GraphAuthorizationClient.ReadStandingAsync, with a GraphStandingSubject |
Standing: a named subject |
GET /api/v3/graph/definitions/entity-types/{entityType}/relations/{relation} |
none. GraphDefinitionsClient.ListRelationsAsync sends the same group's list, GET /api/v3/graph/definitions/entity-types/{entityType}/relations |
The relation-definition read |
POST /api/v3/graph/resources/init |
GraphRegistrationClient.InitResourceAsync; the outbox lane graph.init |
Init |
PUT /api/v3/graph/entities/{entityType}/{entityId}/metadata |
GraphRegistrationClient.UpsertMetadataAsync; the outbox lane graph.metadata |
Metadata |
POST /api/v3/graph/entity-deleted |
the outbox lane graph.delete only; no direct method |
Delete |
Which writes must go through the outbox is the guide's Data integrity: the outbox.
What the write methods return
Every write returns a GraphWriteResult. IsSuccess is true for Applied and AlreadyApplied. A refusal is
GraphWriteStatus.Refused, with the server's Code and Detail. A 5xx or a transport failure throws
GraphOriginUnavailableException. What the issue methods return is
Proxying an invite.
- A
429isGraphWriteStatus.Throttled(access-client1.25), withGraphWriteResult.RetryAfterset from the server'sRetry-After. Wait that long and send again; do not give up.IsSuccessisfalse. Aswitchover the status needs an arm for it — see Upgrading to1.25. InviteToGrantAsynctakes nolifetime(access-client1.25, breaking). The window is the server's.InviteTransferAsyncwithdestinationType: "email"sends the address form:subjectType: "user"plus the address asrecipientAddress(access-client1.25).GrantAsyncfor a subject that can consent, when it does not already hold the relation, isRefusedwithCodeGRANT_INVITATION_REQUIRED(409); on thegraph.grantlane that entry parks. Send the subject throughInviteToGrantAsyncorInviteTeamByAddressAsyncinstead (Direct grant).GrantAsynctakesDateTimeOffset? expiresAt, afterhint(access-client1.26). A granted result carriesGraphWriteResult.Grant, whoseDisplacedResourceIdnames the other resource a granted hint moved off; anullGrantmeans the answer is unknown. No other part of a write's response body is exposed.- 🔴 A re-grant of a relation the subject already holds keeps its existing expiry and hint, and answers
grantedall the same. That is the server's current behaviour; the result cannot tell you the values you sent were not applied.
What the grants query returns
QueryResourceGrantsAsync(resourceType, resourceIds, relations, includeMetadata, pageSize, cursor) returns
GraphReadResult<GraphGrantsPage> (access-client 1.26). It is for a resource-server caller on its service token,
which reads in the entity authority's unfiltered posture; a member table your user sees is the user call. A refusal
is a value carrying the server's code, and only a transport failure, a timeout, a 5xx or an unreadable body throws.
There is no read-all-pages helper: follow NextCursor. The shape is in
Listing who has access to a resource.
Members that cannot succeed on a service token
Each of these sends a user call, and this package authenticates as your service, so sso.api refuses every call
(#431 tracks marking them [Obsolete]):
GraphRegistrationClient.AcceptGrantInvitationAsync— Accept;GraphRegistrationClient.AnswerTransferInvitationAsync(…, TransferAnswer.Cancel)— Cancel;GraphDefinitionsClient.GetPendingGrantInvitationsAsync— Inbox.
GraphRegistrationClient.CancelGrantInvitationAsync is already [Obsolete], for the same reason.
🔴 Cancelling an invitation is the user's own act, sent by your BFF with the user's token — whatever
AnswerTransferInvitationAsync's doc comment says about the authority withdrawing its own offer. That comment is
code, and #431 covers it.
1. Offer a team only where a relation admits one
↪ Moved to the guide: offering a team only on an explicit
teamsubject type, failing closed → Inviting a team from outside the scope; the capability the read needs → The relation-definition read.
var relations = await definitions.ListRelationsAsync(resourceType, cancellationToken);
var teamRelations = relations.Value?
.Where(r => r.SubjectTypes.Contains("team", StringComparer.Ordinal))
.ToList() ?? [];
GraphRelationView.SubjectTypes comes from the definitions API. A missing capability reads as IsForbidden, so no
relation is offered to a team.
2. Resolve the token for display
↪ Moved to the guide: the resolve request, the card and how to render it, and its refusals → Resolve.
This package has no resolve method: resolve is a user call.
3. Check your own rules at submit
↪ Moved to the guide: what sso.api checks on the named grantor at issue → What issue answers; the rules your backend checks itself, at submit → The gates it owns.
4. Invite
var result = await registration.InviteTeamByAddressAsync(
resourceType: "profile",
resourceId: profileId,
resourceName: "Acme Analytics",
relation: "direct_editor",
addressToken: token,
grantorUserId: signedInUserId,
cancellationToken);
↪ Moved to the guide: which id to send as
grantorUserId, why a token invitation is always pending, and what to tell your user → Must implement, What issue answers; the refusals and the rate limit → Issue.
result.Invitation.Outcome is always Invited for a token invitation; read it as
Proxying an invite describes, including a null. The result carries no
team id.
409 TEAM_ADDRESS_LINK_UNRESOLVABLE arrives as GraphWriteStatus.Refused with that Code. It means "ask for a
new link", also for a card that resolved a moment ago.
A 429 is GraphWriteStatus.Throttled (above). Inviting one team to many
resources with one retained token is where you meet it.
GraphRegistrationClient logs nothing, and no refusal echoes the token.
The invite is a direct call; no outbox lane ships for it.
5. Redact the token in your own logs
↪ Moved to the guide: the token is a bearer credential, how to store it, and the redaction pattern → What an address link does and does not do.
6. Do not grant a team — or a user — directly
↪ Moved to the guide: which subjects direct grant refuses, and what a direct grant is for → Direct grant. How the refusal arrives from
GrantAsyncis above.
7. What revoking a link does — and does not do
↪ Moved to the guide: revoking a link stops new use, not accepted access; holding the team's id afterwards; the copy your UI must not show → What an address link does and does not do.
8. Show pending invitations
↪ Moved to the guide: the invitation-list request, who may read it, and its rows → The invitation list, its admission, its fields.
This package has no method for it: the list is a user call.
Deployment checklist
↪ Moved to the guide: the entity-authority registration, the service client's and end users' capabilities, the OAuth application's role, and no CORS entry → Deployment prerequisites, a superset of the list this chapter held.
Proxying an invite — granted or invited
A resource server that proxies an invite for its UI calls GraphRegistrationClient.InviteToGrantAsync or
InviteTransferAsync, and must tell the inviter which of two things happened. On a 2xx the result's
Invitation property says:
result.Invitation |
Meaning | What the UI shows |
|---|---|---|
Outcome == Granted |
the relation is already live — the server converted the invite on the spot. InvitationId and ExpiresAt are null: there is nothing pending to cancel or wait on |
"has access" |
Outcome == Invited |
an invitation is pending; the relation lands when the recipient accepts. InvitationId and ExpiresAt identify it |
"invited, expires …" |
null |
unknown — see below | neither |
var result = await registration.InviteToGrantAsync(/* … */);
if (!result.IsSuccess) return Refused(result.Code, result.Detail);
return result.Invitation switch
{
{ Outcome: GrantInvitationOutcome.Granted } => AlreadyLive(),
{ Outcome: GrantInvitationOutcome.Invited } i => Pending(i.InvitationId, i.ExpiresAt),
null => await ReReadStanding(), // unknown -- never guess
};
Invitation is a body property on GraphWriteResult, so its constructor and Deconstruct are unchanged. It is set
only by the three invite methods (InviteToGrantAsync, InviteTransferAsync, InviteTeamByAddressAsync); every
other write leaves it null.
An older server. A server that predates the two outcomes answers { invitationId, expiresAt, tokenIssued } with
no outcome. It only ever invited, so an invitationId without an outcome reads as Invited.
🔴 A null Invitation after a 2xx means UNKNOWN. The write happened, but the body did not parse or named an
outcome this client does not recognise. Status stays Applied and nothing throws — the write is not a failure —
but never present it as invited, and never as granted. Either reading tells the inviter something false about
live access. Re-read the subject's standing (GraphAuthorizationClient.ReadStandingAsync, or the grants query) and
show what that says.
🔴 A 5xx from issue does not mean nothing happened. The server answers 503 COMMIT_OUTCOME_UNKNOWN when it
cannot tell whether its commit landed, and this client surfaces every 5xx as GraphOriginUnavailableException
without that code. So after the exception a granted may already be live. A retry never grants twice: a grant that
already exists is absorbed and answered granted. But a retry can record a second pending invitation (and send a
second mail) if the first attempt took the invitation path, because grant invitations have no uniqueness. So re-read
standing and the resource's pending invitations first, and retry only if neither shows the first attempt — and do not
tell the inviter the invite failed.
🔴 Never call issue, revoke-all or leave while holding a ResourceLock key yourself. sso.api takes its keys
before any transaction, in ordinal key order (revoke-all and leave take the resource and user:{target}; a converted
invite takes user:{invitee}). A caller that already holds, say, workspace:W and then calls issue for someone that
a concurrent revoke-all on W is removing forms a cycle across Redis that no deadlock detector sees. It ends as
503 RESOURCE_LOCK_UNAVAILABLE after the 10 s waits, not as a hang, but it is avoidable.
Counting a subject's grants — the quota primitive
CountTuplesAsync answers one question: how many resources of one type does this subject hold this relation
on? It exists so a resource server can enforce a quota — "a trial account may create one workspace", "this
plan allows five connectors".
🔴 The engine does not know what a quota is, and this method does not change that. It returns a number. Every limit, plan and refusal stays in your service, which is where the product rules live.
GraphReadResult<long?> result = await graph.CountTuplesAsync(
resourceType: "profile",
relation: "resource_admin",
subjectType: "user",
subjectId: userId.ToString("D"),
cancellationToken);
// `Value` is `long?` ON PURPOSE — see below. There is no number until you unwrap one.
if (result.Value is not { } used)
{
// Refused, not answered. `IsForbidden` means a missing capability or authority:
// a deployment problem, NOT "the user has none".
throw new InvalidOperationException(result.Detail ?? result.Code);
}
if (used >= plan.MaxProfiles) return Results.Conflict("PROFILE_QUOTA_EXCEEDED");
🔴 Value is long?, and that is load-bearing. GraphReadResult<T> declares T? Value, and on an
unconstrained T the nullable annotation is erased for value types — so GraphReadResult<long>.Value would
be a plain long, and a refusal would carry 0. For a quota 0 means "quota free", so
var used = result.Value; on a misconfigured service would grant unlimited creation. The nullable carrier makes
that unwritable: there is no number to read until you unwrap one.
What it counts, and what it does not
| Counted | Not counted |
|---|---|
| direct tuples naming this exact subject | anything reached through a team |
| live rows | revoked rows |
| unexpired rows | rows past expires_at |
| tuples of a different relation or resource type |
🔴 A TEAM GRANT IS NOT COUNTED, AND THAT IS THE ONE THAT WILL SURPRISE YOU. Granting
profile:p1#resource_admin@team:eng lets every member of eng administer p1, and a count for
user:alice still returns 0 — because Alice is not the subject of that tuple. A relation that your product
also grants to teams cannot be soundly quota'd by this method. Either quota a relation you only ever grant
directly, or accept that the number is a floor rather than a total.
🟠 The count is ESTATE-WIDE. The engine's tuple row carries no tenant column, so the number is the subject's total across every tenant. That is usually what you want — a user belongs to one tenant — but a per-plan limit computed from a global total over-enforces if your subjects span tenants. Check that assumption before you use it as a denominator.
🔴 The count is ADVISORY — you own the serialisation
Two concurrent creates both read 4 under a limit of 5, and both proceed. You end up with 6.
The graph holds no lock on a quota it does not own, and it cannot: the number is true when it is returned and stale immediately after. Closing that window is the calling service's job, and the usual ways are cheap:
- a unique constraint or partial index on the thing being limited — the database refuses the sixth row;
- a conditional insert (
INSERT … WHERE (SELECT count(*) …) < 5) in the same transaction as the create; - an advisory lock keyed on the subject for the duration of the check-and-create.
Reading the count and then creating, with nothing in between, is the one pattern that does not work.
Failure is never a number
🔴 Never let an unreachable engine mean "quota free". 0 is a perfectly ordinary count, so a fabricated one
is indistinguishable from a real answer — and it fails open, which on a quota means unlimited creation.
The method refuses to fabricate, and distinguishes the two ways a call can not-succeed:
| Outcome | Meaning | What to do |
|---|---|---|
GraphOriginUnavailableException |
unreachable, timed out, 5xx, unreadable body, or a body carrying no usable count |
fail the operation. Do not default to 0, and do not default to the limit either |
result.IsForbidden ("FORBIDDEN") |
this service is not the registered entity authority for the type and its app does not hold access:graph:count_tuples |
a deployment problem, not a user-facing one. It is permanent until configuration changes, so surface it as a misconfiguration rather than a denial |
That split is deliberate: reporting a missing capability as "the engine is down" sends whoever investigates to the wrong system.
Who may call it
Two ways in, and the first needs no capability at all:
| Admitted | How |
|---|---|
the registered entity authority for resourceType |
matched on your service's client_id; bounded to types you own |
any caller holding access:graph:count_tuples |
any resource type |
🟠 access:graph:read_all_tuples does NOT admit here. Counting is a lesser power than enumerating, so it has
its own key rather than riding on the estate directory-listing one. If your service only ever counts types it
already owns, you need no capability — do not request one.
Listing who has access to a resource — the convention
🔴 The convention is in the Resource sharing guide: what a member list shows, how its actions are decided, the four steps, and the one pattern every consumer follows, sso.api included. This section is the .NET half: which member of this package answers which question. (2026-09-30, sso.api story
2.121: the convention this section used to state moved to the guide. The text as it stood is archived.)
🟠 The standing read is now askable over HTTP. ReadStandingAsync returns effective permissions,
renderable relations, and model-derived grantable for one resource/subject pair, and
QueryResourceGrantsAsync lists the direct grants (story 1.26).
↪ Moved to the guide: one pattern for every consumer, and the only two differences an in-process consumer may have → member-table rules.
Standing and direct grants are both available
sso.api answers the convention in-process. This package exposes the operation guard, the one-pair standing read,
and the direct-grants list. The user-token routes are sent by your BFF, with the user's token. The numbered rows are
the steps of the guide's member-table rules.
| Convention question | in-process (sso.api) |
over HTTP + this package |
|---|---|---|
| guard an operation | CheckAsync |
✅ GraphAuthorizationClient.CheckAsync → POST graph/check |
| 1 list direct grants | POST …/resources/grants/query |
✅ GraphRegistrationClient.QueryResourceGrantsAsync → GraphGrantsPage |
| 2 what this caller may move | CanGrantAsync |
✅ ReadStandingAsync(...).Value.Grantable → POST graph/standing |
| render effective standing | CallerStandingAsync |
✅ ReadStandingAsync(...).Value.Relations |
| change a user's relations | client-app calls the same route (2.110) |
⚪ POST graph/resources/relations — a user-token route, sent by your BFF, with the user's token; no method here, by design |
| leave a resource | client-app calls the same route (2.110) |
⚪ POST graph/resources/relations/leave — a user-token route, sent by your BFF, with the user's token; no method here, by design |
| remove a user from a resource | client-app calls the same route (2.110) |
⚪ POST graph/resources/relations/revoke-all — a user-token route, sent by your BFF, with the user's token; no method here, by design |
| preview that removal | client-app calls the same route (2.110) |
⚪ POST graph/resources/relations/revoke-all/impact — a user-token route, sent by your BFF, with the user's token; no method here, by design |
🔴 The typed methods send this package's service token, so they answer for your service principal.
QueryResourceGrantsAsync is for a resource-server caller on its service token: it reads in the entity authority's
unfiltered posture (the grants query).
ReadStandingAsync answers for a named subject when you pass a GraphStandingSubject
(standing for a named subject).
A member table your user sees is built from the user calls your BFF sends.
↪ Moved to the guide: the relations, leave, revoke-all and impact routes — their bodies, gates, codes and the service-token refusal — are Managing and removing members; when a resource server grants directly is Direct grant; who may name a standing subject is Standing: a named subject; why
grantableis never computed by the caller is the member-table rules. This package has no method for the four member-management routes: each is a user call.
The .NET side of the four steps
↪ Moved to the guide: the four steps and the source of truth for each, the grants query's request, rows, paging, hints, grouping,
includeInherited,revocableandexpiresAt, deciding every action fromgrantable, and what a removal drops → member-table rules, the grants query, Revoke-all.
Step 1, the direct grants. GraphRegistrationClient.QueryResourceGrantsAsync(resourceType, resourceIds, relations, includeMetadata, pageSize, cursor) returns GraphReadResult<GraphGrantsPage> — its rows are
GraphGrantSubjectRow, with Relations as GraphHeldGrant(Relation, ExpiresAt, Hint), Labels, and NextCursor.
It reads one page per call, and the package has no read-all-pages helper: pass NextCursor back as cursor, with
the same filters, until it is null.
🟢 A refusal is a value. A 4xx comes back as a result with a null value and the server's code
(NOT_TYPE_AUTHORITY, CURSOR_INVALID, …), FORBIDDEN for a 403 that names none, otherwise HTTP_<n>. Only a
transport failure, a timeout, a 5xx, or a body in a shape the client does not read throws
GraphOriginUnavailableException.
Steps 2 and 3, standing. Call ReadStandingAsync(resourceType, resourceId) for the authenticated caller, or pass a
GraphStandingSubject to name one. Value.Grantable is what the selected subject may grant or revoke here;
Value.Permissions and Value.Relations are its effective standing.
Why not CheckAsync for the list?
GraphAuthorizationClient.CheckAsync answers "may this subject do this thing here" — resolving the full
effective mask, walking group grants and parent edges. It is the right call for guarding an operation and
the wrong one for building a list: it cannot tell you who has access, only whether one subject does, and
its answer includes standing that has no tuple on this resource.
Guard operations with CheckAsync. Build direct-grant lists from the grants query. Decide on permissions,
render relations, and act on grantable from ReadStandingAsync.
↪ Moved to the guide: the worked reference — sso.api's own surfaces that implement the four steps — and what to do when one disagrees with the convention → member-table rules.
No packages depend on hexasync.access.client.
.NET 10.0
- DistributedLock.Core (>= 1.0.9)
- MessagePack (>= 3.1.8)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.12)
- Npgsql (>= 10.0.3)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.3)
- Snappier (>= 1.3.1)
- ZstdSharp.Port (>= 0.8.8)
- hexasync.services.proxy (>= 1.10.58)
| Version | Downloads | Last updated |
|---|---|---|
| 2610.24.2 | 0 | 10/24/2026 |
| 2610.24.1 | 0 | 10/24/2026 |
| 2610.19.2 | 1 | 10/19/2026 |
| 2610.17.1-pre.3 | 1 | 10/17/2026 |
| 2610.17.1-pre.2 | 0 | 10/17/2026 |
| 2610.15.1 | 0 | 10/15/2026 |
| 2610.7.2 | 0 | 10/07/2026 |
| 2610.7.1 | 0 | 10/07/2026 |
| 2610.3.2 | 0 | 10/03/2026 |
| 2610.3.1 | 0 | 10/03/2026 |
| 2610.2.2 | 0 | 10/02/2026 |
| 2610.2.1 | 0 | 10/02/2026 |
| 2610.1.2 | 0 | 10/01/2026 |
| 2610.1.1 | 26 | 10/01/2026 |
| 2609.29.2 | 0 | 09/29/2026 |
| 2609.29.1 | 0 | 09/29/2026 |
| 2609.28.2 | 17 | 09/28/2026 |
| 2609.28.1 | 0 | 09/28/2026 |
| 2609.25.2 | 0 | 09/25/2026 |
| 2609.25.1 | 2 | 09/25/2026 |
| 2609.23.2 | 2 | 09/23/2026 |
| 2609.23.1 | 0 | 09/23/2026 |
| 2609.22.1 | 1 | 09/22/2026 |
| 2609.16.2 | 0 | 09/16/2026 |
| 2609.16.1 | 7 | 09/16/2026 |
| 2609.15.2 | 2 | 09/15/2026 |
| 2609.15.1 | 3 | 09/15/2026 |
| 2609.12.2 | 4 | 09/12/2026 |
| 2609.12.1 | 1 | 09/12/2026 |
| 2609.10.5 | 1 | 09/11/2026 |
| 2609.10.4 | 44 | 09/10/2026 |
| 2609.10.3 | 2 | 09/10/2026 |
| 2609.10.2 | 0 | 09/10/2026 |
| 2609.10.1 | 4 | 09/10/2026 |
| 2609.8.2 | 1 | 09/08/2026 |
| 2609.8.1 | 7 | 09/08/2026 |
| 2609.7.2 | 0 | 09/07/2026 |
| 2609.7.1 | 16 | 09/07/2026 |
| 2609.4.2 | 0 | 09/04/2026 |
| 2609.4.1 | 4 | 09/04/2026 |
| 2609.4.1-pre.2 | 0 | 09/04/2026 |
| 2609.4.1-pre.1 | 2 | 09/04/2026 |
| 2609.3.1-pre.2 | 3 | 09/03/2026 |
| 2609.3.1-pre.1 | 2 | 09/03/2026 |
| 2609.2.1-pre.3 | 2 | 09/02/2026 |
| 2609.2.1-pre.2 | 1 | 09/02/2026 |
| 2608.27.1-pre.2 | 3 | 08/27/2026 |