hexasync.access.client 2610.24.1

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

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant RS as Consumer backend participant C as Access HTTP client participant T as Service-token resolver participant IAM as SSO token / graph endpoints RS->>C: Graph operation C->>T: Resolve current service token T->>IAM: Client credentials when exchange/refresh is needed IAM-->>T: Service JWT T-->>C: Nonblank current token C->>IAM: Graph call with service bearer token alt Eligible first 401 C->>T: Gated one-time refresh T-->>C: Refreshed token C->>IAM: Replay once else Normal response / noneligible failure Note over C,IAM: No general retry or fallback<br/>decision end C-->>RS: Actual response / documented exception
services
    .AddHexaSyncAccessClient(options =>
    {
        options.Timeout = TimeSpan.FromSeconds(30);
    })
    .UseEcdsaServiceIdentity(configuration);

This chain registers:

  • GraphAuthorizationClient, GraphDefinitionsClient, GraphRegistrationClient, and ProjectionReader through IHttpClientFactory;
  • IProjectionSource = HttpProjectionSource through TryAdd, so an existing local implementation wins;
  • per-send Authorization: Bearer attachment for every graph client and the graph-outbox named client;
  • the standard hexasync.services.proxy client-credentials resolver and its gated one-time 401 refresh/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

flowchart LR H["Consumer chooses custom auth/transport"] --> R["AddHexaSyncAccessClient with explicit valid origin"] R --> A["Attach consumer delegating handler to all four clients + named outbox client"] A --> P["Optional custom primary transport"] P --> C["Graph request through consumer-owned authentication"] C --> O["Actual result; no implicit standard token resolver"] N["Omit UseEcdsaServiceIdentity in this path"] --> R

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 via POST /api/v3/graph/resources/init.
  • graph.metadata — update an entity's public metadata via PUT /api/v3/graph/entities/{type}/{id}/metadata.
  • graph.delete — report that an authoritative entity was deleted via POST /api/v3/graph/entity-deleted.
  • graph.grant — grant or revoke a relation, selected by the payload operation, via POST /api/v3/graph/resources/grants or /grants/revoke.
  • graph.transfer — transfer a single-valued relation via POST /api/v3/graph/resources/transfer.

Wire the outbox — three steps

1. Put the tables in your model

flowchart TD C["Existing consumer context"] --> O{"Use outbox?"} O -->|Yes| G["Explicit ApplyGraphOutboxModel"] O -->|No| P{"Use local projections?"} G --> P P -->|Yes| M["Explicit ApplyProjectionModel"] P -->|No| E["Scaffold one consumer migration + designer + snapshot"] M --> E E --> D["Apply to the runtime database/schema"] D --> W["Wire only the selected features"]

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 EXISTS does nothing to a database that already has the table, so every column added after the first release had to arrive as an appended ALTER 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 next migrations add could not see them.

An IEntityTypeConfiguration describes the shape as it is, not as a history of edits. Your migrations add computes 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

flowchart LR D["Matching data source + lock provider"] --> I["Standard service identity / chosen auth handler"] I --> N["Required real Notify and delivery origin"] N --> R["AddGraphOutbox"] R --> P["Driver, lane processors and independent retention"] P --> W["Committed outbox rows delivered by background 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

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant O as Owning domain mutation participant DB as Consumer connection / transaction participant P as Publisher O->>DB: BEGIN and domain write / EF SaveChanges O->>DB: Enqueue concrete typed payload with caller event ID O->>DB: COMMIT (CancellationToken.None) Note over O,DB: Domain and outbox atomic,<br/>ordered IDs for multiple entries P->>DB: Read committed entries after local transaction Note over O,P: Local commit is publication<br/>intent, not effective IAM access

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 NpgsqlConnection and NpgsqlTransaction second, and then commit both together. With EF Core, changing a tracked entity is not the mutation: SaveChangesAsync must complete before the enqueue. An independent producer that opens its own connection or transaction, or an enqueue issued before the database UPDATE, breaks the outbox's atomicity or ordering guarantee. GraphOutboxWriter cannot 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:

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant DB as Global outbox participant D as Driver lease holder participant P as Typed processors participant IAM as Graph origin D->>DB: Bounded (xid, id) scan below safe horizon DB-->>D: Global ordered batch, all lanes D->>P: Contiguous runs without reordering P->>IAM: One ordered entry at a time (service identity) IAM-->>P: Delivered / parked / retryable alt Delivered or parked D->>DB: Advance cursor else Retryable / lost response Note over D,IAM: Keep cursor, replay first entry,<br/>every later lane waits end

Independent retention:

flowchart TD L["Retention service acquires its own lease"] --> C["Read global cursor"] C --> D["Two bounded retention DELETEs at/below cursor"] D --> S["Preserve entries ahead of cursor"] S --> W["Wait RetentionInterval"] W --> C P["Driver lease may be held by a different pod"] -.-> N["No shared driver/retention coordination needed"]

🟢 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 — a 401 retries 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 as 409 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

flowchart TD T["Consumer typed payload + explicit serializer metadata"] --> R["Register typed lane processor"] R --> E["Domain write then typed enqueue in owning transaction"] E --> C["Commit"] C --> D["Driver supplies contiguous ordered run"] D --> P["Processor delivers in order and returns per-entry outcomes"] P --> O["Advance/park or stop at retry without skipping later work"]

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

flowchart TD N["Next globally ordered entry"] --> R{"Outcome"} R -->|2xx| D["Delivered, advance"] D --> N R -->|Permanent allowlisted 400/409 or cannot send| P["Park in graph_outbox_dead, notify, advance"] P --> N R -->|Anything else| W["Retry forever, no advance; every later lane waits"] W --> S["Configured delay, no backoff or Retry-After handling"] S -->|Replay the same entry| N P -.-> O["Separate operator triage under read/requeue policies"] O --> Q["Requeue as a fresh entry ID, preserving trace"] Q --> J["New entry joins the global ordered walk; not immediate IAM reconciliation"]
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.

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant O as Authorized operator participant D as Dead-letter API / store participant DB as Consumer database O->>D: List parked metadata under read policy D-->>O: No payload in list response Note over O: Investigate permanent<br/>refusal and choose a<br/>safe correction O->>D: Explicit requeue under requeue policy D->>DB: Insert new outbox entry with fresh ID D-->>O: Requeue result, not successful reconciliation

What to alert on

flowchart TD R["Driver round finishes"] --> O{"Retry / delivered / empty"} O -->|Retry| F["Set first_stuck_at if unset; preserve failure start"] F --> E{"StuckAfter elapsed?"} E -->|No| W["Wait configured round delay"] E -->|Yes| T{"Notice throttle permits?"} T -->|Yes| N["Notify last error + failing lane"] T -->|No| W N --> W W --> R O -->|Delivered or empty| C["Clear first_stuck_at; idle cannot alert"] C --> W L["Cursor lag / pinned horizon"] -.-> X["Not this notice's trigger"]

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:

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant C as Remote projection consumer participant S as ProjectionReader / source participant IAM as SSO / IAM C->>S: OpenAsync for exact resource type and subject S->>IAM: Fetch (chosen HTTP authentication) alt Refused / unavailable S-->>C: Actual refusal / failure, not authoritative empty else Open response stream S-->>C: Remote stream C->>S: Consume complete stream and dispose end Note over C,IAM: This fetch creates no tables and<br/>persists no consumer generation

Ensure — warm candidates or a complete cold rebuild:

flowchart TD E["Ensure exact subject/type"] --> H{"Active generation exists?"} H -->|Yes, any age| W["Serve warm candidates"] W --> S{"At least five minutes and queue supplied?"} S -->|Yes| Q["Queue background refresh"] S -->|No| C["Continue local candidate query"] Q --> C H -->|No| L["Wait for rebuild under subject/type lock"] L --> R{"Complete active generation obtained?"} R -->|Yes| C R -->|No| U["Unavailable, not authoritative empty"]

Writer refresh — only complete persisted generations become active:

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant W as Projection writer participant S as IProjectionSource participant DB as Consumer PostgreSQL W->>S: Open exact subject/type projection alt Refused / transport failed Note over W,DB: No new generation, preserve<br/>existing active candidates else Stream begins W->>DB: Persist new inactive generation S-->>W: Enumerate complete row stream alt Truncated / failed Note over W,DB: Never activate partial<br/>generation else Complete ingestion W->>DB: Flip active generation atomically end end Note over W,DB: Superseded writer result is not<br/>a promise its ID remains active

Local read — no fetch or implicit refresh:

flowchart LR O["ProjectionLocalReader.OpenAsync"] --> H{"Active header?"} H -->|Absent| A["Absent: Ensure/rebuild required"] H -->|Present| S["Fresh/Stale state + local detail stream"] S --> E{"Details empty?"} E -->|Yes| Z["Active empty generation, not Absent"] E -->|No| D["Candidate resource IDs + signed masks"] Z --> N["Neither state is local operation authorization"] D --> N
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:

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant E as Request Ensure participant Q as Shared refresh queue participant W as Refresh worker participant DB as Worker connection participant S as Projection source E->>Q: Queue stale warm subject/type Q-->>W: Deduplicated work W->>DB: Own scope/connection, acquire real lock W->>S: Read complete stream Note over W,DB: Complete inactive generation<br/>then atomic flip, failed refresh<br/>preserves existing active<br/>candidates

Maintenance and cache loss:

flowchart TD S["Sweep"] --> R["Reap old inactive generations"] S --> D["Deactivate active generations older than 24 hours"] R --> N["No origin fetch or permission/expiry recomputation"] D --> N C["UNLOGGED crash/cache loss"] --> A["Absent active generation"] A --> E["Next Ensure cold rebuild"] E --> O["Complete candidates or explicit unavailability"]

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 LeaderLockName to 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

  1. QueryResourceGrantsAsync returns the grouped shape, and GraphGrantRow is gone. It returned one flat row per tuple, and the server stopped sending that shape in invitation-unification 1.29 — so every row carried Relation = null and ExpiresAt = null, and nothing failed. It now returns GraphReadResult<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 403 or a 429 is now a value, not an exception. A caller that caught GraphOriginUnavailableException for either must check IsSuccess. A read carries no RetryAfter.
    • Code is the server's, unlike this package's other reads, so IsForbidden is false for NOT_TYPE_AUTHORITY: a missing capability and a missing entity-authority registration are fixed in different places.
    • It requires an sso.api that returns labels (iam 2.118). A body without grants, labels or a row's relations throws rather than reading as empty.
  2. GrantAsync gains DateTimeOffset? expiresAt, after hint. 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 answers 409 EXPIRY_NOT_ADMITTED_ON_RELATION, as a Refused result.

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 INSERT by the package and nothing at all by you. It also retires a column named lane that had held the constant * since 1.16 and 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

Graph-read caller budget (sso.api 12.49). Grants/query and tuples/count share 10 requests per 60 seconds per caller, in-handler rather than per BFF IP. User calls use the user's sub; this package's service calls use client_id. Effective access:rate_limit:bypass skips only that budget, never capability checks, route authorization or visibility. Existing capability sync grants it to SUPER_ADMIN; internal services require explicit operator grants, not an automatic service exemption. The wire refusal is E3 429 IAM_RATE_LIMITED with Retry-After rounded up to whole seconds, at least 1; missing stable non-bypass identity answers E3 403 UNATTRIBUTABLE_CALLER. Existing grants refusals remain E1. QueryResourceGrantsAsync reads either envelope into its existing refusal result, without adding a Retry-After result carrier or retry policy. A throttled member read is unreadable, not an empty roster; IAM 2.118 labels and 2.124 table payload/rendering rules are unchanged. Standing remains 30/min (#414).

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

flowchart TD W["SDK write response"] --> R{"Actual response"} R -->|Applied / AlreadyApplied| S["IsSuccess=true"] R -->|Refused| F["Preserve Code and Detail"] R -->|429| T["Throttled with RetryAfter"] R -->|Transport / 5xx| U["GraphOriginUnavailableException"] S --> I{"Invitation method?"} I -->|Yes| O["Interpret granted / invited / unknown body separately"] I -->|No| D["Actual method result, no invented invitation"]

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 429 is GraphWriteStatus.Throttled (access-client 1.25), with GraphWriteResult.RetryAfter set from the server's Retry-After. Wait that long and send again; do not give up. IsSuccess is false. A switch over the status needs an arm for it — see Upgrading to 1.25.
  • InviteToGrantAsync takes no lifetime (access-client 1.25, breaking). The window is the server's.
  • InviteTransferAsync with destinationType: "email" sends the address form: subjectType: "user" plus the address as recipientAddress (access-client 1.25).
  • GrantAsync for a subject that can consent, when it does not already hold the relation, is Refused with Code GRANT_INVITATION_REQUIRED (409); on the graph.grant lane that entry parks. Send the subject through InviteToGrantAsync or InviteTeamByAddressAsync instead (Direct grant).
  • GrantAsync takes DateTimeOffset? expiresAt, after hint (access-client 1.26). A granted result carries GraphWriteResult.Grant, whose DisplacedResourceId names the other resource a granted hint moved off; a null Grant means 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 granted all 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

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant C as Resource authority backend participant SDK as GraphRegistrationClient participant IAM as SSO / IAM C->>SDK: QueryResourceGrantsAsync with page/cursor SDK->>IAM: Grants query (service identity) alt Readable authority posture IAM-->>SDK: GraphGrantsPage with grouped rows/labels/NextCursor SDK-->>C: Value, caller follows cursor explicitly else Refused SDK-->>C: Result with refusal code else Transport / timeout / 5xx / unreadable body Note over C,SDK: Throw documented failure, never<br/>fabricate no grants end Note over C,IAM: User member table uses<br/>user-token BFF, not this<br/>authority read

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

flowchart LR M["Accept / cancel / inbox SDK member"] --> S["Package supplies service identity"] S --> R["IAM user-call subject/capability gate refuses"] R --> B["Use BFF forwarding with actual user bearer token instead"] B --> N["No user impersonation or invented SDK fallback"]

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

flowchart TD R["ListRelationsAsync(resourceType) with service identity"] --> A{"Readable definitions?"} A -->|Forbidden / no usable Value| N["Offer no team relation"] A -->|Unavailable| U["Report dependency failure, not permission"] A -->|Usable| F["Filter SubjectTypes explicitly containing team"] F --> P["Offer only these relations for the later invite"]

↪ Moved to the guide: offering a team only on an explicit team subject 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

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant U as Inviter participant RS as Resource authority participant SDK as GraphRegistrationClient participant IAM as SSO / IAM U->>RS: Authenticated invite with pasted team token Note over RS: Real grantor, trusted<br/>local resource name,<br/>relation explicitly<br/>admits team RS->>SDK: InviteTeamByAddressAsync SDK->>IAM: Issue (service identity, addressToken) alt Confirmed invited / refused / throttled IAM-->>SDK: Actual result SDK-->>RS: Typed write result RS-->>U: Display actual pending/refusal/throttle result else Unknown body / transport / 5xx Note over SDK,RS: No confirmed invitation outcome RS->>IAM: Reread grants/standing and pending invitations IAM-->>RS: Current observed state or unreadable RS-->>U: Show observed state or uncertainty before any retry end Note over U,IAM: Redact token on every path Note over U,IAM: Token form never auto-accepts,<br/>recipient consent still required
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 GrantAsync is above.

↪ 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

flowchart TD I["Invite SDK method returned or threw"] --> S{"Known successful outcome?"} S -->|Granted| G["Show live access, no pending ID"] S -->|Invited| P["Show pending ID/expiry, not live access"] S -->|Refused / throttled| R["Preserve refusal or RetryAfter"] S -->|2xx unknown body / transport / 5xx| U["Outcome unknown, not failed safely"] U --> Q["Reread actual standing/grants and resource invitations"] Q --> O["Show observed state; retry only after checking for first outcome"] O --> N["Pending issue can duplicate on retry; no idempotency promise"]

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

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant RS as Resource consumer participant SDK as GraphAuthorizationClient participant IAM as SSO / IAM RS->>SDK: CountTuplesAsync(type, relation, exact subject) SDK->>IAM: Count (service JWT) Note over IAM: Admit type authority<br/>or count_tuples<br/>capability alt Usable count IAM-->>SDK: Direct live unexpired tuple count SDK-->>RS: Nullable Value contains number Note over RS: Apply own<br/>product/quota rules,<br/>scope assumptions and<br/>serialization else Refused SDK-->>RS: Forbidden/refusal value, no usable number else Unreachable / unreadable / 5xx Note over SDK,RS: Throw unavailable, never return<br/>fabricated zero end Note over RS,IAM: Team-inherited access is not<br/>counted, number is advisory

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.

Transport budget is separate from your quota. Approved sso.api 12.49 applies the shared 10/60s graph-read caller budget described above. CountTuplesAsync preserves its current refusal mapping: HTTP 429 IAM_RATE_LIMITED becomes a result with code HTTP_429 and no usable count; 403 (including UNATTRIBUTABLE_CALLER) remains FORBIDDEN. It does not expose Retry-After or retry automatically. Never turn these refusals into zero, quota-free access or an empty result. Effective access:rate_limit:bypass skips only the budget; read_all_tuples still does not admit a count.

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

%%{init: {"sequence": {"wrap": true, "width": 220}}}%% sequenceDiagram participant A as Create A participant B as Concurrent create B participant IAM as Advisory count A->>IAM: Count current direct grants B->>IAM: Count current direct grants IAM-->>A: 4 of limit 5 IAM-->>B: 4 of limit 5 Note over A,B: Both can pass a read-only check<br/>Consumer must<br/>serialize/atomically enforce the<br/>owned limit Note over IAM: Count API owns no<br/>consumer quota lock or<br/>domain insert

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

flowchart TD C["Count response"] --> V{"Usable nullable Value?"} V -->|Yes| N["Use actual number under consumer-owned product rules"] V -->|Forbidden| F["Configuration/capability issue, not quota free"] V -->|Dependency / malformed response| U["Fail unavailable, not zero or invented limit"]

🔴 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

flowchart TD Q["Consumer question"] --> R{"What is being decided?"} R -->|Exact protected operation| C["Target-bound CheckAsync + consumer policy"] R -->|Effective named-subject position| S["Privileged SDK standing read"] R -->|Direct authority roster| G["SDK grants query, unfiltered posture"] R -->|User member UI/read/write| B["User-token BFF IAM routes"] B --> U["Own standing, grouped direct rows, pending flags and actual actions"] C --> N["None is replaced by local list candidates or visible UI"]

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 grantable is 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, revocable and expiresAt, deciding every action from grantable, 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.

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