hexasync.access.client 2609.10.5

hexasync.access.client

The client package for the HexaSync graph authorization engine. It exposes five related surfaces:

Surface Purpose
GraphAuthorizationClient ask the engine a permission question
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.

Register the HTTP clients

Standard ECDSA service identity

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

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 commit 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 global commit order. The walk reads every lane in one (xid, id)-ordered pass — not a cursor per lane — so an entry committed before another is delivered before it whatever lanes they are on. That is what makes it safe for the engine's metadata upsert to carry no monotonic guard.

Five lanes ship with the package:

  • 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

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 if you read projections
}
call it when tables
ApplyGraphOutboxModel() you publish graph changes — i.e. you registered the driver in step 2 graph_outbox, graph_outbox_state, graph_outbox_dead
ApplyProjectionModel() you read projections locally — ProjectionReader, ProjectionLocalReader, ProjectionSweepService projection_header, projection_detail

They are independent: publishing without reading and reading without publishing are both ordinary. Calling one you do not use costs you two empty tables in your migration, so leave it out.

⚠️ 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

services.AddGraphOutbox(o =>
{
    o.DeliveryUrl = config["GRAPH_ENGINE_URL"];          // required, checked at boot
    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.

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.

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(),
    GraphOutboxLanes.Init,
    JsonSerializer.Serialize(new
    {
        resourceType = "profile",
        resourceId = profile.Id.ToString("D"),
        relations = new[] { new { relation = "parent", subjectType = "user", subjectId = ownerId.ToString("D") } },
        requestedBy = ownerId.ToString("D"),
    }),
    ct);

await tx.CommitAsync(CancellationToken.None);

🔴 Enqueue AFTER the row update, never before. The row lock is what orders two concurrent mutations of the same entity: the second cannot reach its enqueue until the first commits, so ids come out in commit order. Insert first and they invert — the transaction that has not taken a lock yet gets the lower id, and the later change is delivered as if it were earlier.

🔴 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 shape of the running system

your handler ──┬── UPDATE profiles          ┐
               └── INSERT graph_outbox      ┘ one transaction

                        ▼

  GraphOutboxService  (one per pod; exactly one holds `graph-outbox-driver`)
        │
        └─ ScanOnceAsync(LIMIT 100)      ONE cursor, every lane, in (xid, id) commit 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

🟢 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 commit 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 tolerates double delivery against an idempotent destination. Do not remove that on the strength of the lock.


Adding your own lane

Implement the interface and register it. Nothing else changes:

public sealed class DeviceProjectionProcessor : IGraphOutboxLaneProcessor
{
    public string Lane => "myservice.devices";

    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) the entry moves to graph_outbox_dead and the lane continues
anything else retried forever, and the 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.

A parked entry is logged at Warning every pass, and raises a notice through your Notify delegate. 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.

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

A projection is a local copy of the engine's answers for one subject: which resources they can touch, and with which bits. You refresh it on a schedule or on a signal, then answer permission questions from your own database instead of over the network.

Two tables, and ApplyProjectionModel() is what puts them in your migration:

projection_header one row per generation — a complete, self-consistent snapshot. Exactly one is active
projection_detail one row per resource in that generation, carrying the 64-bit mask

A refresh writes a whole new generation and flips active at the end, so a reader never sees a half-built one. The superseded generation is then dead weight, which is what the sweep is for:

services.AddSingleton(new ProjectionSweepOptions { DataSource = dataSource });
services.AddHostedService<ProjectionSweepService>();

🟠 ProjectionSweepService deliberately takes NO leader lock, and that is not an oversight — its statements are idempotent, each filtered by a partial index and empty in the common case, so running it on every pod is the same as running it on one. Its failures cost storage, not correctness. (Contrast the outbox's retention sweep, which is bounded and therefore does take one — see Options.)

🔴 Both tables are UNLOGGED. They are a cache of someone else's authoritative data, they are rewritten wholesale on every refresh, and skipping the WAL is most of the point. They come back EMPTY after an unclean shutdown — so treat an absent projection as "refresh it", never as "this subject has no access".


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


Listing who has access to a resource — the convention

Owner-ruled, 2026-09-08. Every resource's member/access list follows this. Do not invent a second way.

"In member list of a resource, we always list the direct grant only." "Action permission will be calculated base on permission bit mask, not the relation."

The workspace surface in sso.api has done this since story 1.29/2.12, in-process, and needs nothing added there. The convention exists because two surfaces answering the same question differently is how they start disagreeing — not because the pattern is unproven.

🟠 The standing read is now askable over HTTP. ReadStandingAsync returns effective permissions, renderable relations, and model-derived grantable for one resource/subject pair. The remaining package gap is step 1: the direct-grants route exists, but this package still has no typed client for it.

🔴 One pattern for EVERY consumer — and sso.api is a consumer, not an exception

Owner-ruled, 2026-09-08: "the graph engine consumer pattern should be same across consumer, include sso itself. Only some exception like outbox is not needed cause same process, or can call public method direct instead of the http call."

So there are exactly two permitted differences for an in-process consumer, and they are both mechanical:

Permitted Why
no outbox the outbox exists to make a cross-process delivery transactional. In-process, your write and the engine's write are already one transaction.
a direct method call instead of HTTP same question, same answer, one less hop.

🔴 Nothing else. Not a different read, not a different shape, not a private route, not a decision made somewhere else. If an in-process consumer needs a question answered, the same question must be askable over HTTP — otherwise the pattern is not shared, it is privileged.

Standing is available; the direct-grants client is the remaining gap

sso.api answers the convention in-process. This package now exposes the operation guard and the one-pair standing read; the direct-grants route remains HTTP-only.

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 🟠 the route exists; no typed client here
2 what this caller may move CanGrantAsync ✅ ReadStandingAsync(...).Value.Grantable → POST graph/standing
render effective standing CallerStandingAsync ✅ ReadStandingAsync(...).Value.Relations

Omit subject to ask about the authenticated caller; either access:graph:read_standing or access:graph:read_standing_all admits that posture. Supplying subject requires access:graph:read_standing_all, even when it spells the caller.

🔴 DO NOT COMPUTE grantable YOURSELF. It is CanGrant over the authored model; re-deriving it from relation names or a role is how two services start disagreeing about who may grant what, and it is exactly what D102 forbids. Decide on permissions, render relations, act on grantable.

The four steps

# Step Source of truth
1 list the direct grants, grouped one row per subject POST /api/v3/graph/resources/grants/query
2 learn what this caller may move the resource's own read publishes grantable
3 render verbs and options from grantable — never from a relation name your UI
4 a removal drops every direct relation the subject holds your write route

1. The list is direct grants, grouped by subject

POST /api/v3/graph/resources/grants/query
capability: access:graph:read_visible_grants

{ "resourceType": "...", "resourceIds": ["..."], "includeMetadata": true }

Response — GrantsQueryResponse → GrantSubjectRow[]:

resourceId · subjectType · subjectId · relations[{relation, expiresAt}] · revocable · subjectMetadata · truncated

🟢 THE SERVER GROUPS. One row per (resource, subject) with the relations inside it. Do not group client-side, and do not paginate a per-relation shape: a subject's two relations can land on opposite sides of a page boundary, and a client-side join then renders one member as two rows each showing half their relations — with revocable computed from half the truth.

🟢 includeInherited defaults to false, and leaving it false IS the convention. A member list shows the direct grants — the tuples that exist on this resource. Inherited access is a different question; asking it here produces rows nobody can act on, because there is no tuple behind them.

🟢 revocable is STATED, never inferred. It is ANDed across a subject's relations. Read the field; do not deduce from relation names which rows have a tuple behind them.

🟢 expiresAt stays on the RELATION, not the row — a subject may hold two relations expiring at different times, so one value per row could only be a choice among them.

2 and 3. grantable decides every action — not a relation name

Call ReadStandingAsync(resourceType, resourceId) for the authenticated caller, or supply GraphStandingSubject only when the calling application holds access:graph:read_standing_all. Read grantable as the relations that selected subject may grant or revoke here. It is CanGrant, which is (callerMask & grant_requires) == grant_requires — a bit-mask decision over the authored model, evaluated server-side.

So the UI needs no authority logic at all:

Question Answer
does any verb render for this row? grantable.length > 0
what may I change? grantable
what does the subject hold that I may not change? held \ grantable — show it read-only, never as a disabled control
what does a form offer? grantable, for the user to pick

🟢 grantable.length > 0 is EXACT, not a proxy. If it is non-empty, pick any t in it: either the subject holds t, so it may be revoked, or they do not, so it may be granted. Either way an action exists. If it is empty, none does. Do not "improve" this into per-row set arithmetic — it reduces to this.

🔴 NEVER ask a permission in place of a relation set. A permission is satisfied by a SET of relations including derived ones — a relation reached through a parent or a group. So a permission check answers true for a caller or target whose standing is inherited and who holds no tuple here, while the list shows no row for them. Deciding a row's verbs from a permission check therefore disagrees with the list it sits next to. The list is direct grants; the decision must be over the same set.

🔴 NEVER hardcode a relation name in a decision. D102: business logic may hardcode permissions, never relations. A hardcoded 'direct_member' needs editing the day a second membership-conferring relation is declared — and the model is authored data, so that day is an operator's edit, not a release. Two known shapes of this mistake, both from real code:

  • gating a control on grantable.includes('direct_member') instead of grantable.length > 0;
  • a form that hardcodes the relation it grants instead of granting the one the user chose from grantable.
  • deciding authority with relations.Any(r => r.Relation == "direct_admin") instead of reading permissions or grantable.

🟠 A WRITE may name what it confers — a command route called "add a member" must say which relation it grants, because that is its contract. A form is not a command: if it offers a choice, the choice comes from grantable.

4. Removal drops every direct relation

Gate it on authority over all of them, then drop them all:

holds = the subject's direct relations here      // from step 1
if (!holds.All(grantable.Contains)) refuse
else revoke every relation in holds

🟢 Naming no relation is what makes this adapt. grantable already encodes grant_requires, so a relation nobody may grant is absent from every caller's set, and a relation only a higher authority may grant is absent from a lower one's — with no name and no bit appearing in your code.

🔴 Dropping only ONE relation is the defect this forecloses. A partial removal leaves a subject holding some standing while losing the rest, which then needs a guard naming the relations it must not leave behind — and that guard is where the relation names come back. Removing everything makes the guard unnecessary rather than dynamic.

🟠 A member count counts DISTINCT SUBJECTS. Counting tuple rows double-counts anyone holding two relations.

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.

Worked reference

sso.api's workspace Access surface implements all four steps, and its team surface is being brought onto the same pattern rather than keeping its own. If something here is ambiguous, read that first — and if it disagrees with this section, the disagreement is a bug in one of them and worth reporting rather than working around.

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