hexasync.access.client 2609.15.2
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, andProjectionReaderthroughIHttpClientFactory;IProjectionSource = HttpProjectionSourcethroughTryAdd, so an existing local implementation wins;- per-send
Authorization: Bearerattachment for every graph client and the graph-outbox named client; - the standard
hexasync.services.proxyclient-credentials resolver and its gated one-time401refresh/replay.
Required configuration:
| Key | Owner | Used for |
|---|---|---|
SSO_SERVICE_URL |
deployment | both /api/v3/auth/token and graph traffic |
SSO_CLIENT_ID |
consuming service | OAuth client identity |
SSO_CLIENT_SECRET |
secret store | OAuth client credential |
There is deliberately no second graph URL in the standard path. UseEcdsaServiceIdentity takes
ClientCredentialsOptions.SsoUrl as the graph origin and appends /api/v3/ to the existing relative graph
routes. A ServiceUrl value supplied in the options lambda is therefore replaced in standard ECDSA mode.
| Concern | Owner |
|---|---|
| JWT signing and token endpoint | SSO |
token exchange, distributed cache, single-flight refresh, one eligible 401 replay |
hexasync.services.proxy |
graph HttpClient composition and current-token attachment |
hexasync.access.client |
| OAuth credentials, domain policy, persistence, and distributed locks | consuming service |
Timeout defaults to 30 seconds and must be positive. For projections it bounds connection and response
headers; the caller's cancellation token bounds streamed row enumeration. The default primary handlers do not
follow redirects. A blank token fails before transport: the three JSON clients retain their
GraphOriginUnavailableException mapping, while ProjectionReader retains its documented
HttpRequestException contract. No general retry, fallback decision, or authorization cache is added.
Registration is idempotent. It is safe if the host already called AddEcdsaInnerServiceResolver, and repeating
the complete access registration chain does not duplicate token handlers or 401 replay. Standard mode refuses
to replace a deliberately registered non-standard IEcdsaInnerServiceTokenResolver.
Custom authentication or transport
Hosts with local or custom authentication omit UseEcdsaServiceIdentity, set the graph origin explicitly, and
add their own delegating handler:
services.AddTransient<MyGraphAuthenticationHandler>();
services
.AddHexaSyncAccessClient(options =>
{
options.ServiceUrl = "https://graph.internal.example";
options.Timeout = TimeSpan.FromSeconds(15);
})
.AddHttpMessageHandler<MyGraphAuthenticationHandler>();
ServiceUrl must be an absolute HTTP or HTTPS origin with no path, user-info, query, or fragment.
AddHttpMessageHandler<THandler>() applies the handler to all four graph clients and the graph-outbox named
client without exposing internal client names. ConfigurePrimaryHttpMessageHandler(...) is available when a
host or test must replace the terminal transport.
The outbox, in one paragraph
Your service owns some entities — profiles, tenants, devices. The engine needs to know they exist and what
they are called, and it must learn this exactly when your write commits and never otherwise. So you write
a row into graph_outbox inside your own transaction, and a background driver walks that table in 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 viaPOST /api/v3/graph/resources/init.graph.metadata— update an entity's public metadata viaPUT /api/v3/graph/entities/{type}/{id}/metadata.graph.delete— report that an authoritative entity was deleted viaPOST /api/v3/graph/entity-deleted.graph.grant— grant or revoke a relation, selected by the payload operation, viaPOST /api/v3/graph/resources/grantsor/grants/revoke.graph.transfer— transfer a single-valued relation viaPOST /api/v3/graph/resources/transfer.
Wire the outbox — three steps
1. Put the tables in your model
The package owns the shape; you own the migration. There are two model extensions, and you call the one(s) whose feature you use:
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyGraphOutboxModel(); // graph_outbox, graph_outbox_state, graph_outbox_dead
modelBuilder.ApplyProjectionModel(); // projection_header, projection_detail -- only 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 EXISTSdoes nothing to a database that already has the table, so every column added after the first release had to arrive as an appendedALTER TABLE … ADD COLUMN IF NOT EXISTS. That list only grows, and it is applied by whatever you remember to run. The failure that actually happened is worse than untidy: a consumer ran the script as raw SQL from inside a migration, so the tables existed in its database and nothing at all was in its model snapshot — and the nextmigrations addcould not see them.An
IEntityTypeConfigurationdescribes the shape as it is, not as a history of edits. Yourmigrations addcomputes the diff, and your snapshot carries the tables, so the migration after this one is computed against the truth.
⚠️ ApplyConfigurationsFromAssembly on your own assembly will not find these — they live in this one.
That is why the call is explicit.
2. Register the driver
services.AddGraphOutbox(o =>
{
o.DeliveryUrl = config["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.
Required unit-of-work boundary: the consumer's mutation-owning method — the application or repository method that begins and commits the transaction — must execute the local database mutation first, enqueue on that exact
NpgsqlConnectionandNpgsqlTransactionsecond, and then commit both together. With EF Core, changing a tracked entity is not the mutation:SaveChangesAsyncmust complete before the enqueue. An independent producer that opens its own connection or transaction, or an enqueue issued before the databaseUPDATE, breaks the outbox's atomicity or ordering guarantee.GraphOutboxWritercannot detect either mistake because it does not know which consumer row the event represents.
await using var tx = await connection.BeginTransactionAsync(ct);
// … your mutation, FIRST …
await UpdateTheProfileRow(connection, tx, profile, ct);
// … then the enqueue, on the SAME connection and transaction …
await GraphOutboxWriter.EnqueueAsync(
connection,
tx,
Guid.CreateVersion7(),
new GraphInitOutboxPayload(
"profile",
profile.Id.ToString("D"),
[new GraphInitOutboxRelation("parent", "user", ownerId.ToString("D"))],
ownerId),
ct);
await tx.CommitAsync(CancellationToken.None);
🔴 Enqueue AFTER the 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 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 ──┬── 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 — a401retries forever, so it stalls the whole outbox. Permanent failures still park and advance, so the exposure is narrower than it first looks. This was chosen because the alternative is worse and silent: per-lane cursors preserve commit order inside a lane and discard it between lanes, so once a delete lane exists a delete can overtake a metadata write and the row it deleted comes back. Nothing errors and nothing logs. A blocked queue is visible delay; resurrection is silent corruption.
One leader. Every pod starts the driver; one acquires the lock and works, the rest stand by and take over on eviction. Standby is the normal state, not an error. If the leader loses its lease mid-pass it stops immediately — a holder that kept working after eviction would produce the exact double-driver state the lock exists to prevent.
🔴 THE LOCK IS THE SINGLE-WRITER GUARANTEE, and YOU own the lease TTL. This paragraph used to say the opposite — that the lock bought "waste, not correctness" and that a conditional claim in SQL was what stopped two pods notifying. That second guard was removed by owner ruling on 2026-09-03: only the driver calls the notifier, it runs under the lock, and a lease long enough to outlast any real pause makes a second concurrent caller a case not worth carrying code for.
What that puts on you, because this package cannot do it: it takes
DistributedLock.Core— the abstraction only — so the expiry is set where you construct your provider. Configure a lease that comfortably outlasts your worst pause, and remember it is the same knob as failover: a leader that is OOM-killed leaves the outbox undrained until its lease expires.🟢 Still true and still load-bearing: the walk tolerates double delivery against an idempotent destination. Do not remove that on the strength of the lock.
Adding your own lane
Define a typed payload, implement the typed processor interface, and register the processor:
public sealed record DeviceProjectionPayload(string DeviceId)
: IGraphOutboxPayload<DeviceProjectionPayload>
{
public static string Lane => "myservice.devices";
public static JsonTypeInfo<DeviceProjectionPayload> JsonTypeInfo
=> DeviceOutboxJsonContext.Default.DeviceProjectionPayload;
}
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(DeviceProjectionPayload))]
internal sealed partial class DeviceOutboxJsonContext : JsonSerializerContext;
await GraphOutboxWriter.EnqueueAsync(
connection,
tx,
Guid.CreateVersion7(),
new DeviceProjectionPayload(deviceId),
ct);
public sealed class DeviceProjectionProcessor
: IGraphOutboxLaneProcessor<DeviceProjectionPayload>
{
public async Task<IReadOnlyList<DeliveryOutcome>> DeliverAsync(
IReadOnlyList<GraphOutboxEntry> run, CancellationToken ct)
{
var outcomes = new DeliveryOutcome[run.Count];
for (var i = 0; i < run.Count; i++) // IN ORDER. One at a time.
{
var response = await _client.PostAsync("…", Body(run[i].Payload), ct);
outcomes[i] = DeliveryOutcome.FromStatus((int)response.StatusCode);
if (!outcomes[i].IsDelivered && !outcomes[i].ShouldPark)
{
// not attempted -- nothing was decided about these, so they are retries
for (var rest = i + 1; rest < run.Count; rest++)
outcomes[rest] = DeliveryOutcome.Retry("not attempted");
break;
}
}
return outcomes;
}
}
services.AddScoped<IGraphOutboxLaneProcessor, DeviceProjectionProcessor>();
🔴 DELIVER THE RUN IN ORDER, ONE AT A TIME. This is the one rule the batch shape hands to you, and nothing in
the type system enforces it. await Task.WhenAll(run.Select(Send)) looks like an obvious throughput win and
it reproduces, inside a single run, the out-of-order delivery the single cursor exists to remove. If a run ever
has to be pipelined, the partition must be the entity, not the operation.
🔴 Exactly one outcome per entry, in the same order. An entry you chose not to attempt is a Retry — that
is what not attempted means. A count that does not match the run fails the whole run and is logged as a
processor defect; it is never read as "the rest succeeded", which would advance the cursor past entries nobody
classified.
🟢 Why a list and not one status for the run. One status cannot say "9 delivered, 1 parked, 2 not attempted" — and being able to say that is what keeps stop at the first failure, never skip and who advances the cursor inside the scanner, where they are written once, instead of in every processor you ever write.
Return, don't throw, for an expected refusal. A throw is caught and treated as a retry, which is the right default for a broken transport — it keeps deliverable data out of the dead-letter table.
What happens when delivery fails
| Response | What the driver does |
|---|---|
| 2xx | delivered; the cursor advances past it |
permanent (the 400/409 allowlist) |
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
LeaderLockNameto avoid exactly that, you get a distinct retention lock for free. A fixed literal would have given you one retention leader globally and left the second application's table unswept, silently.
Why a lock here at all, when ProjectionSweepService deliberately has none? Because this sweep is bounded.
The projection sweep is unbounded and convergent — running it N times equals running it once, so a lock would buy
only saved round trips. Retention removes at most RetentionBatchSize per pass, so N unsynchronised pods remove up
to N× the ceiling that option promises — and that ceiling exists for the first run against a backlog, which is
exactly when it matters.
Upgrading to the typed outbox writer
GraphOutboxWriter now exposes only EnqueueAsync<TPayload>. The overload taking separate kind and
payloadJson strings is removed with no compatibility shim. Replace anonymous/raw JSON producers with one of the
five standard payload records, or implement IGraphOutboxPayload<TPayload> for a custom lane. A custom payload
must supply source-generated JsonTypeInfo<TPayload> and must not serialize a root lane property or use JSON
extension data.
Processors for custom lanes implement IGraphOutboxLaneProcessor<TPayload> so enqueue and delivery obtain the
lane from the same payload type. The caller still supplies the event ID, mutation connection, and transaction.
Upgrading to 1.18 + 1.20 — one migration, three breaking changes
These two ship together. Scaffold ONE migration after taking the package, not two:
dotnet ef migrations add GraphOutboxState --project <your-ef-project>
1. graph_outbox_cursor is DROPPED and replaced by graph_outbox_state. It was one row of typed columns; it
is now (key text primary key, value text null). The nine facts keep their names as keys — last_scanned_xid,
last_scanned_id, last_scan_at, failed_attempts, last_error, last_dead_letter_at,
last_dead_letter_notification_at, first_stuck_at, last_stuck_notification_at.
Why. Every fact this package tracked about itself was a column, so adding one cost you a migration — three releases in a row did exactly that, and a fourth wanted a housekeeping timestamp and declined to add it rather than make you pay again. A new fact is now an
INSERTby the package and nothing at all by you. It also retires a column namedlanethat had held the constant*since1.16and had not meant a lane since.
🔴 Your development database re-delivers everything once, for the same reason and with the same consequences as
the 1.16 note below: the new table starts empty and the walk starts at zero. Harmless — the destination is
idempotent — but do not be surprised by it. There is no data migration; nothing carries the old row forward.
🟠 If you have not yet scaffolded 1.16's index re-cut, both land in this one migration. Then 1.16's
checklist item — "a DropIndex and a CreateIndex, and nothing else" — no longer holds: you will also see the
cursor table dropped and the state table created. That is correct; do not reject it.
2. GraphOutboxSchema is GONE. Both of its public consts go with it — CursorLane (there is no keyed cursor
row to name) and SeedCursorSql (there is no seed: reads coalesce an absent key to its default and every write is
an upsert). If you referenced either, GraphOutboxState's key constants are the replacement.
3. GET …/lanes always answers 200; the 404 arm is deleted. The endpoint's result type changes from
Results<Ok<GraphOutboxHealth>, NotFound> to Ok<GraphOutboxHealth> — source-breaking if you matched on the union.
The response body is unchanged. 404 used to mean "nothing has ever scanned", which was only ever safe
because the old cursor row was seeded on every pass; without a seed a healthy-but-idle outbox writes nothing at
all, so keeping the arm would have answered 404 for ever on a perfectly working deployment.
🟢 Not breaking, but new: retention moved out of the delivery pass into its own hosted service —
AddGraphOutbox registers it, so there is nothing for you to wire. See Options for its lock.
Upgrading to 1.16 — three breaking changes
1. IGraphOutboxLaneProcessor.DeliverAsync takes a RUN and returns one outcome per entry. Every processor
stops compiling, which is the intent — see Adding your own lane above for the shape, and the ordering rule that
comes with it.
2. GET …/lanes answers with ONE object, and ALWAYS 200. The scan and notification stamps live in
graph_outbox_state and cover every lane. A client reading .lanes[0] gets nothing rather than an error. 🔴 Since
1.18 the 404 arm is gone: package state has no seeded row, so a healthy-but-idle outbox has simply written
nothing and every stamp is null. The response record is unchanged; only the endpoint's result type is.
3. A LOCAL database re-delivers everything once. The cursor is keyed on a constant instead of a lane name, so an existing development database keeps its old per-lane rows — nothing reads them — and the new row starts at zero. Harmless: the destination is idempotent and re-delivery is the failure mode this design is built for. There is no data migration and no production deployment holds cursor rows.
🟠 The migration is a schema change only, and it is the usual one: migrations add from the model. It drops
ix_graph_outbox_walk and recreates it on (xid, id). Do not skip it — the walk no longer filters on kind,
so against the old (kind, xid, id) index the keyset has an unbound leading column and every pass degrades to a
sequential scan and a sort of the whole table. Probed against postgres:18, 60 000 rows.
Two failure modes worth recognising
"Everything is healthy and the graph is stale." The driver catches every exception a pass throws, logs it, and sleeps. So a misconfiguration produced an outbox that looked alive and delivered nothing — which is why the URLs are checked at boot and the host refuses to start without them.
An entry whose lane has no registered processor is PARKED, immediately. It cannot become deliverable by
retrying — only by a deployment — and under one cursor a retry would block every entry behind it. The payload
survives in graph_outbox_dead and POST /{id}/requeue replays it once the processor ships. 🟠 So a producer
deployed before its processor sends its whole stream to the dead table, and requeue is one row at a time: a
bulk route is deliberately not in the package, because the recovery scale is bounded by how long a producer runs
without its processor. The driver refuses to scan at all while no processors are registered, which is what
keeps that case recoverable.
"Registered and idle" looks exactly like "not registered at all." A consumer enqueued onto both lanes for a
month while nothing drained them, because wiring the outbox took six registrations per lane and nobody did it.
That is what AddGraphOutbox exists to prevent — and why the driver logs a Warning, every pass, when no
processors are registered.
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 ofgrantable.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 readingpermissionsorgrantable.
🟠 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.
.NET 10.0
- DistributedLock.Core (>= 1.0.9)
- MessagePack (>= 3.1.8)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.4)
- Npgsql (>= 10.0.3)
- Npgsql.EntityFrameworkCore.PostgreSQL (>= 10.0.3)
- Snappier (>= 1.3.1)
- ZstdSharp.Port (>= 0.8.8)
- hexasync.services.proxy (>= 1.10.58)
| Version | Downloads | Last updated |
|---|---|---|
| 2610.24.2 | 0 | 10/24/2026 |
| 2610.24.1 | 0 | 10/24/2026 |
| 2610.19.2 | 1 | 10/19/2026 |
| 2610.17.1-pre.3 | 1 | 10/17/2026 |
| 2610.17.1-pre.2 | 0 | 10/17/2026 |
| 2610.15.1 | 0 | 10/15/2026 |
| 2610.7.2 | 0 | 10/07/2026 |
| 2610.7.1 | 0 | 10/07/2026 |
| 2610.3.2 | 0 | 10/03/2026 |
| 2610.3.1 | 0 | 10/03/2026 |
| 2610.2.2 | 0 | 10/02/2026 |
| 2610.2.1 | 0 | 10/02/2026 |
| 2610.1.2 | 0 | 10/01/2026 |
| 2610.1.1 | 26 | 10/01/2026 |
| 2609.29.2 | 0 | 09/29/2026 |
| 2609.29.1 | 0 | 09/29/2026 |
| 2609.28.2 | 17 | 09/28/2026 |
| 2609.28.1 | 0 | 09/28/2026 |
| 2609.25.2 | 0 | 09/25/2026 |
| 2609.25.1 | 2 | 09/25/2026 |
| 2609.23.2 | 2 | 09/23/2026 |
| 2609.23.1 | 0 | 09/23/2026 |
| 2609.22.1 | 1 | 09/22/2026 |
| 2609.16.2 | 0 | 09/16/2026 |
| 2609.16.1 | 7 | 09/16/2026 |
| 2609.15.2 | 2 | 09/15/2026 |
| 2609.15.1 | 3 | 09/15/2026 |
| 2609.12.2 | 4 | 09/12/2026 |
| 2609.12.1 | 1 | 09/12/2026 |
| 2609.10.5 | 1 | 09/11/2026 |
| 2609.10.4 | 44 | 09/10/2026 |
| 2609.10.3 | 2 | 09/10/2026 |
| 2609.10.2 | 0 | 09/10/2026 |
| 2609.10.1 | 4 | 09/10/2026 |
| 2609.8.2 | 1 | 09/08/2026 |
| 2609.8.1 | 7 | 09/08/2026 |
| 2609.7.2 | 0 | 09/07/2026 |
| 2609.7.1 | 16 | 09/07/2026 |
| 2609.4.2 | 0 | 09/04/2026 |
| 2609.4.1 | 4 | 09/04/2026 |
| 2609.4.1-pre.2 | 0 | 09/04/2026 |
| 2609.4.1-pre.1 | 2 | 09/04/2026 |
| 2609.3.1-pre.2 | 3 | 09/03/2026 |
| 2609.3.1-pre.1 | 2 | 09/03/2026 |
| 2609.2.1-pre.3 | 2 | 09/02/2026 |
| 2609.2.1-pre.2 | 1 | 09/02/2026 |
| 2608.27.1-pre.2 | 3 | 08/27/2026 |