Revoke entitlement

Revoke a previously granted entitlement.

Source src/operations/entitlements.ts#L64-L86 · revokeEntitlementsrc/operations/registry.ts#L52

Revoke entitlement

revokeEntitlement removes a user's ownership of a SKU — the mirror of grantEntitlement. Ownership is a record, not a balance. No money moves, and the ledger is untouched.

You name the user and the SKU, and optionally a reason for the audit trail:

const outcome = await economy.submit({
  kind: "revokeEntitlement",
  idempotencyKey: "idem_revoke_1",
  actor: { kind: "system", service: "fulfillment" },
  userId: "usr_owner",
  sku: "wrld_pass",
  reason: "chargeback",
});
// → outcome.status === "committed"

Once this commits, read.entitled("usr_owner", "wrld_pass") returns false. Any UI that gates access on that SKU stops granting it.

Parameters

The payload is one revokeEntitlement variant of Operation, tagged by kind. The fields:

FieldTypeDefaultDescription
kind"revokeEntitlement"Selects this operation.
idempotencyKeystringMakes a retried request run at most once; a repeat with the same key returns the earlier result. See idempotency.
actorPrincipalWho is asking. Must be system or operator — see Authorization.
userIdstringThe user losing ownership. A blank or whitespace-only value throws.
skustringThe item or feature code to revoke, such as "wrld_pass". A blank or whitespace-only value throws.
reasonstring— (omitted)Optional note for the audit trail.

Returns

revokeEntitlement returns an Outcome:

  • committed — the user owned the SKU and the ownership record was dropped.
  • duplicate — a request with the same idempotencyKey already ran; the earlier transaction is returned unchanged.
  • rejected — the user did not own the SKU. The only RejectionCode here is NOT_ENTITLED.

The committed transaction is a lifecycle marker. It carries a fresh id and commit time. Its legs and links lists are empty, because nothing posted to the ledger.

Postings

None. Entitlements track ownership, not value. So revokeEntitlement writes no double-entry posting and touches no account.

The handler drops the ownership record through unit.entitlements.revoke. The returned transaction exists only because a committed outcome must carry one; its leg and link lists are empty.

Authorization

revokeEntitlement is platform-initiated only. An end user (actor.kind === "user") may never run it — see actors and authorization.

A revoke names an arbitrary userId the caller need not own. It also posts no debit the ownership check could catch. So it is gated up front: only a system service or a human operator may call it.

A user actor is declined with an UNAUTHORIZED fault before any work begins.

Reason codes

revokeEntitlement returns one RejectionCode:

CodeWhen
NOT_ENTITLEDThe user does not currently own sku, so there is nothing to revoke.

The rejection carries the userId and sku in its detail, so the caller knows which ownership check came back empty.

A blank or whitespace-only userId or sku is not a rejection — it throws a MALFORMED_OPERATION fault. A blank user would revoke ownership against a phantom user, and a blank SKU against nothing. So a malformed request surfaces as a client error rather than a silent NOT_ENTITLED.

Preconditions and invariants

The handler checks ownership before it revokes. It calls entitlements.owns(userId, sku) and returns NOT_ENTITLED when the user does not hold the SKU. A revoke of something never granted is therefore a clean decline, not an error.

Revoking is idempotent through the idempotencyKey. A retry under the same key returns the first result as duplicate rather than re-running. So a revoke applies at most once per key, even under retries.

The ledger's money invariants — conservation, no-overdraft, and backing — are unaffected, because this operation moves no money.

See also