Reference · 11

Authorization and retention.

Check every caller, and limit how long retry results are kept.

Authorize every call

define({authorize}) sets a private query that runs before every public method call, retry replay, cached read and watch refresh. Return a principal to allow the call; return null or throw to deny it (403 FORBIDDEN). Without a hook, every method is public.

APIContract
HistoryIdentity{database:string,incarnation:string}, 128-bit hex. Stable across restarts and moves; changes only on a fenced disaster restore.
ctx.history()This database's HistoryIdentity, or null before retention is initialized. Queries and mutations only.
Principal{subject: string, tenant?: string, claims?: Json}. subject and tenant must be nonempty. In a named partition, tenant must equal the partition name.
AuthorizationRequest{credentials: Json, method: string, args: Json, partition: string|null, delegation: {coordinator:string,principal:Principal|null}|null}. credentials defaults to null. delegation is set for cross-group transactions; your hook decides which coordinators to trust.
ctx.principal()The admitted principal in a query or mutation, or null for public methods. Not available in derived values or transaction plans.
FlowerClientOptions.credentialsJSON credentials, or a (possibly async) function returning them per call. RequestOptions.credentials overrides it for one request. A watch keeps its credentials until it closes; reconnect with fresh ones after expiry.
  • Credentials are separate from args. A retry with refreshed credentials for the same subject and tenant gets the original result; a different caller can't.
  • Check tokens in the hook, not only inside a mutation, or a retry replay could skip the check.
  • Protected methods always read fresh policy, so even replica-local methods then need a quorum.
  • Watches recheck on each refresh and close when denied. Data already sent can't be taken back.
  • Authorization doesn't secure peer or admin traffic. Protect the network too.

Limit how long retry results are kept

By default Flower keeps every retry result forever. Retention lets you drop old ones. It's opt-in and uses numbered epochs, not clock time:

  1. Initialize retention once.
  2. Issue request IDs scoped to the current epoch.
  3. Advance the minimum epoch. Older request IDs are then rejected for good.
  4. Collect the retired results in batches.
APIContract
RetryIdentity{database,incarnation,currentEpoch,minEpoch}. IDs are 32 lowercase hex characters; epochs are nonnegative integers.
RetentionStateRetryIdentity plus receiptBytes, receiptCount, maxReceiptBytes (number|null), gcCursor (string|null), gcComplete, sessionBytes, sessionCount, gcReceiptsComplete and gcSessionCursor.
RetentionActionOne of: initialize {database,incarnation,max_receipt_bytes}; advance {incarnation,current_epoch,min_epoch}; collect {incarnation,limit}; set_budget {incarnation,max_receipt_bytes}; reincarnate {incarnation,new_incarnation,fence_attestation}, each with operation. Epochs never go down. reincarnate is for disaster restore only, after you have fenced the old cluster yourself.
client.retentionStatus(options?)Current RetentionState, or null. Admin token.
client.controlRetention(expectedRevision, action, options?)Apply an action if the revision matches. Returns {state, collected}. After a lost response, check status before sending anything else.
client.refreshRetryIdentity(options?)Reload the current epoch for new request IDs. Doesn't update old IDs. Fails if retention isn't initialized.
client.newRequestId(intent?, options?)Make an epoch-scoped request ID (random intent by default). Save it before sending and reuse it on retry. The same intent in a new epoch is a new request.
RetrySession{database,incarnation,id,epoch,acknowledgedThrough,closed}, owned by the caller's principal. Needs an authorization hook, which sees $flower.session.open, .status, .ack and .close.
SessionOptionsRequestOptions plus limit? (default 256 records per call) and abandon? (default false, ACK only).
client.openRetrySession(id?, options?)Open a session. Pick and save your own 32-character hex ID first so you can recover a lost response. Reopening an active session is safe; closed ones can't be reopened.
client.sessionRequestId(session, sequence)Request ID for a sequence number above the ACK mark. You track sequence numbers; the SDK never advances or ACKs for you.
client.retrySessionStatus({id,incarnation}, options?)Current session state, for the owner only. Use it after an uncertain ACK.
client.acknowledgeRetrySession(session, through, options?)Drop results up to through. ACK only after you've saved them. Every result up to that point must exist, unless abandon: true, which also cancels unknown outcomes for good. Retries at or below the mark return ALREADY_ACKNOWLEDGED and never run again.
client.closeRetrySession(session, options?)Close the session for good. In-flight results may be lost. The ID can't be reused.
FlowerClientOptions.boundedRetriesDefault false. When true, generated request IDs use the current epoch. Your own IDs pass through unchanged. The SDK doesn't refresh the epoch for you.

Errors to handle:

  • RETRY_WINDOW_EXPIRED: the result is gone. The mutation may still have committed.
  • HISTORY_MISMATCH: the ID belongs to a different database history.
  • RECEIPT_BUDGET_EXCEEDED: new work is rejected; kept results are never evicted.

Once initialized, unscoped request IDs are rejected. A disaster restore needs a new incarnation; restoring an old backup as-is is unsafe. Backups may still contain deleted results. Details: retention protocol.

Cleaning up cross-group transactions

Records of finished cross-group transactions are removed only when you close and collect them. Prepared transactions without a decision are never released.

APIContract
TransactionClosureTarget{group,partition:string|null,epoch,addresses?:string[]}. Placement info; not used for routing.
TransactionClosureState{history:string|null,nextSequence,closedThrough,pending,blockedReason:string|null,deletedRecords}. pending is null or {through,participants,acknowledged}. blockedReason explains a stall, such as an unfinished transaction or an unreachable peer.
TransactionClosureAction{operation:"close",through?:number,maxBytes?:number} or {operation:"collect",maxBytes?:number}. through defaults to all finished transactions; maxBytes defaults to the node's transaction budget.
client.transactionClosureStatus(options?)Current closure state. Admin token; works on root and partition URLs.
client.controlTransactionClosure(action, options?)close tells participants to reject late messages, then advances the floor. collect deletes old records; run it on coordinators and participants. Safe to repeat. Check pending and blockedReason, not just HTTP success.

An aborted transaction can close only once its request ID can no longer be retried, so old unscoped aborts block closure until retention is initialized.