Notifications and subscriptions¶
ONE Record pushes: a holder tells subscribers when an object is created or updated, when an event is posted on it, when access is granted, and when an action request changes status. The SDK decides who must be told and what; the host does the sending.
Receiving¶
POST /notifications accepts an api:Notification from any authenticated
party, validates it, raises Server\Event\NotificationReceived with the
parsed Api\Notification and the sender, and answers 204. Nothing is stored:
what to do with a notification (fetch the object, update a status) is the
host's business, done in the event listener.
Sending: the outbox¶
Every notification the server wants to send is queued in the
NotificationOutbox as an OutboundNotification: the recipient agent URI,
the Api\Notification, and when it was created. The host drains the outbox
(a queue job, a cron) and delivers each with the SDK client to the recipient's
/notifications endpoint. The server never opens a connection itself, so
egress stays under the host's control and a slow partner never slows a request.
foreach ($outbox->drain() as $outbound) {
// The client is bound to one partner server; resolve the recipient's endpoint
// from your partner registry, or fall back to the spec's derivation.
$endpoint = $partners->endpointOf($outbound->recipient) ?? $outbound->suggestedEndpoint();
$client = $clients->for($endpoint); // one OneRecordClient per partner, cached
$client->sendNotification($outbound->notification, idempotencyKey: $outbound->id);
}
Every OutboundNotification carries an id. Keep it with your delivery row
and never send the same id twice as a new notification. Partners may use the
id to deduplicate; sending it as an Idempotency-Key header is a reasonable
convention until the spec names one.
What an outbox worker looks like¶
Two hosts built this independently and converged on the same shape, so it is written down here as the specification to copy rather than another host's code.
Row. One per OutboundNotification: the SDK id (unique), recipient,
endpoint (resolved at enqueue time, may be null), event type, logistics
object, the notification document as JSON-LD, created-at, attempts,
next-attempt-at, delivered-at, failed-at, last error. The row is written
inside the unit of work with the operation it announces (see the
SPI guide); hand it to the queue from the
connection's after-commit hook, never from enqueue() itself.
Claim. A worker takes a row by one conditional update: attempts goes up
by one and next-attempt-at moves into the future (the lease), only if the
row is still undelivered, unfailed and due. Exactly one worker wins. Record
the outcome with the same attempt number the claim produced, so a worker
that outlived its lease cannot overwrite a later attempt's result.
Outcome. Deliver with the SDK client and classify a failure with
Client\DeliveryVerdict::of($throwable): Retry for transport failures and
for 5xx, 408 and 429 (set next-attempt-at with backoff, give up as failed
after N attempts), Reject for every other HTTP answer and for sending-side
defects (set failed-at at once; an operator may requeue after fixing the
cause). Success sets delivered-at. Delivery is at least once however
carefully this is done; the recipient deduplicates on the id.
Who gets what¶
Server\Notification\Fanout applies the spec's rules:
| Trigger | Event type | Recipients |
|---|---|---|
Object created (DataHolder::create, publish) |
LOGISTICS_OBJECT_CREATED |
Subscribers to the object's type |
| Change request accepted and applied | LOGISTICS_OBJECT_UPDATED with api:hasChangedProperty |
Subscribers to the object or its type |
| Logistics event posted | LOGISTICS_EVENT_RECEIVED with api:hasLogisticsEvent |
Subscribers to the object or its type |
| Access delegation accepted | LOGISTICS_OBJECT_ACCESS_GRANTED |
Each delegate |
| Action request status change, when the requestor asked | *_REQUEST_ACCEPTED / _REJECTED / _REVOKED / _FAILED / _ACKNOWLEDGED |
The requestor |
DataHolder::announce |
LOGISTICS_OBJECT_AVAILABLE |
The named partner |
A subscription lists the event types it wants (api:includeSubscriptionEventType);
others are not sent. With api:sendLogisticsObjectBody: true the object's
current JSON-LD is embedded in api:hasLogisticsObject; otherwise only the
URI is sent. Expired subscriptions (api:expiresAt) are ignored.
The changed properties of an update are the object's own properties, with an
edit inside an embedded node counted for the property it hangs off (changing a
weight's numericalValue reports cargo:grossWeight).
Subscribing to this server¶
A partner sends POST /subscriptions with an api:Subscription: its own
agent URI as api:hasSubscriber, a topic type (LOGISTICS_OBJECT_IDENTIFIER
for one object, LOGISTICS_OBJECT_TYPE for every object of a class) and the
topic. The topic must be an object of this server (a hidden one looks like a
missing one) or a logistics-object class of the ontology. The result is a
pending SubscriptionRequest (201 with its Location) that the holder
accepts or rejects. Only accepted subscriptions receive
notifications; a subscriber revokes with DELETE on the request.
Being asked to subscribe¶
The spec also lets a publisher ask you what you want: GET
/subscriptions?topicType=…&topic=…. The answer comes from
SubscriptionStore::offered(): the subscriptions the host registered as its
interests (InMemorySubscriptionStore::offer()). One offer is answered as an
api:Subscription; none or several as an api:Collection
(spec question 17).
Subscriptions the holder sets up¶
When a partner has answered such a question to you, record the subscription
with DataHolder::subscribe(Subscription): it becomes an accepted
SubscriptionRequest, so notifications reference it and the partner can
revoke it like any other.