hexasync.access.client 2609.2.1-pre.3

hexasync.access.client

The client for the HexaSync graph authorization engine. Four things live here, and only the last one needs a guide:

GraphAuthorizationClient ask the engine a permission question
GraphDefinitionsClient read the model — entity types, relations, permissions
GraphRegistrationClient register this service as an entity authority
Outbox/ publish this service's own changes to the engine, transactionally

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.

Two lanes ship with the package:

  • graph.init — this resource now exists, with these initial relations. Delivered to POST /api/v3/graph/resources/init.
  • graph.metadata — this entity's label changed. Delivered to PUT /api/v3/graph/entities/{type}/{id}/metadata.

Wiring it up — four steps, and none of them is optional

1. Put the tables in your model

The package owns the shape; you own the migration. One line in your DbContext:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    base.OnModelCreating(modelBuilder);
    modelBuilder.ApplyGraphOutboxModel();   // graph_outbox, graph_outbox_cursor, graph_outbox_dead
}

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, both standard processors, the named HttpClient, and the boot-time URL check. It resolves nothing at registration time.

3. Attach your service identity to the delivery client

The package sets the address. You supply the credential — every consumer reaches the engine as itself, and a library that picked a token source would be choosing your infrastructure:

services.AddHttpClient(GraphOutboxHttp.ClientName)
        .AddHttpMessageHandler<InnerServiceTokenHandler>();

Skip this and every entry parks on a 401.

4. Enqueue inside your own transaction

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 the leader lock)
        │
        ├─ lane graph.init ──── ScanOnceAsync(LIMIT 100) ──→ GraphOutboxInitProcessor ──→ engine
        └─ lane graph.metadata ─ ScanOnceAsync(LIMIT 100) ──→ GraphOutboxMetadataProcessor ─→ engine

One driver, every lane. The lanes are the registered processors, not a SELECT DISTINCT(lane). Each gets one bounded pass per cycle, which is what keeps a wedged lane from starving the others: a pass returns after at most BatchSize entries whatever happens inside it.

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 buys waste, not correctness. The cursor claim is an UPDATE, so two pods cannot both notify, and the walk tolerates double delivery against an idempotent destination. Do not remove either of those 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<DeliveryOutcome> DeliverAsync(GraphOutboxEntry entry, CancellationToken ct)
    {
        var response = await _client.PostAsync("…", Body(entry.Payload), ct);
        return response.IsSuccessStatusCode
            ? DeliveryOutcome.Delivered
            : DeliveryOutcome.FromStatus((int)response.StatusCode);
    }
}

services.AddScoped<IGraphOutboxLaneProcessor, DeviceProjectionProcessor>();

One entry, not a batch — and that is deliberate. A batch signature would move two rules into every processor you ever write: stop at the first failure, never skip, and who advances the cursor. Both live in the scanner today, once, and the walk's whole guarantee — delivery in (xid, id) commit order — depends on them. A processor that advanced past its own failure would deliver a later change as if it were earlier.

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.

Lag

A lane cannot advance past the oldest in-flight transaction anywhere in your database. A lane sitting at zero delivered with a non-zero lag is not idle — it is blocked by an unrelated long-running query. The driver logs the lag on every pass, including empty ones, precisely because the symptom otherwise reads as "the graph is stale" with the cause nowhere near it.


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 lane per pass
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

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 a lane that looked alive and delivered nothing — which is why the URLs are checked at boot and the host refuses to start without them.

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

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