Revoke entitlement
Remove a user's ownership of a SKU; declines with NOT_ENTITLED when they never owned it.
Source src/operations/entitlements.ts#L69-L90revokeEntitlementsrc/operations/registry.ts#L66REGISTRY
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',
});
// → { status: "committed", transaction: { id: "txn_…", … } }curl -s https://economy.example/submit \
-H 'content-type: application/json' \
-d '{
"kind": "revokeEntitlement",
"idempotencyKey": "idem_revoke_1",
"actor": { "kind": "system", "service": "fulfillment" },
"userId": "usr_owner",
"sku": "wrld_pass",
"reason": "chargeback"
}'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 fields, beyond the kind tag, are:
| Field | Type | Default | Description |
|---|---|---|---|
idempotencyKey | string | (required) | A retried submit with the same key runs at most once. See idempotency. |
actor | Principal | (required) | Who is asking. Must be system or operator, see Authorization. |
userId | string | (required) | The user losing ownership. A blank or whitespace-only value throws. |
sku | string | (required) | The item or feature code to revoke, such as "wrld_pass". A blank or whitespace-only value throws. |
reason | string | — | Optional note for the audit trail; omitted when not given. |
Returns
revokeEntitlement resolves to an Outcome.
committed: the user owned the SKU and the ownership record was dropped.duplicate: a request with the sameidempotencyKeyalready ran; the earliertransactionis returned unchanged.rejected: the user did not own the SKU. The onlyRejectionCodehere isNOT_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 only, so revokeEntitlement writes no double-entry posting and touches no account.
The handler drops the ownership record through unit.entitlements.revoke. The returned transaction carries empty leg and link lists.
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 AUTH.UNAUTHORIZED fault before any work begins.
Reason codes
revokeEntitlement returns one RejectionCode:
| Code | When |
|---|---|
NOT_ENTITLED | The 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 malformed request throws instead:
| Code | When |
|---|---|
OP.MALFORMED | A blank or whitespace-only userId or sku. |
A blank field would revoke against a phantom user or SKU, 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.
Revoking is idempotent through the idempotencyKey. A retry under the same key returns the first result as duplicate rather than re-running.
The ledger’s money invariants (conservation, no-overdraft, and backing) are unaffected, because this operation moves no money.