The SPI a host implements¶
Everything the server needs from its host is an interface in
LambdaTwelve\OneRecord\Server\Spi. Each has an in-memory implementation in
Server\InMemory that the test suite runs against; a wrapper replaces them one
by one with persistent ones (Eloquent, Doctrine, Drupal's database API) and
keeps the same tests green. The interfaces are deliberately small.
Storage¶
LogisticsObjectStore¶
Revisions of logistics objects. Every revision is kept: the API serves historical reads and the audit trail.
| Method | Contract |
|---|---|
latest(Iri): ?StoredObject |
The current revision, or null |
revision(Iri, int): ?StoredObject |
A specific revision |
at(Iri, DateTimeImmutable): ?StoredObject |
The revision current at that instant |
exists(Iri): bool |
|
create(LogisticsObject, DateTimeImmutable): StoredObject |
Revision 1; throws StoreException if the URI exists |
saveRevision(LogisticsObject, int $expectedCurrent, DateTimeImmutable): StoredObject |
Stores expectedCurrent + 1 only if the current revision is expectedCurrent (optimistic concurrency); throws StoreException otherwise |
erase(Iri): void |
Removes every revision (see Forgetting data) |
StoredObject carries the object, its revision, the latest revision, and the
timestamps. The object graph includes the embedded nodes with their
internal: ids; store it as the JSON-LD the SDK writes (toJson()), or as
triples, whichever suits the database.
LogisticsEventStore¶
Append-only events per object: append, get, query(Iri, EventQuery) with
the spec's filters (event codes, created/occurred ranges, sort, limit, skip),
lastModified(Iri) for the list's Last-Modified, and eraseFor(Iri) for
forgetting. Two rules the contract test pins: appending
an event IRI that already exists is a StoreException (event IRIs are minted
by the server, so a repeat is a bug, never an update), and lastModified is
the newest event's created, not the last one appended. Rebuild events from
storage with LogisticsEvent::fromStored(), which validates nothing.
ActionRequestStore¶
save, get, auditTrail(Iri, AuditTrailQuery) (the change and verification
requests of an object, filtered by time range and status), pendingChanges(Iri),
which the lifecycle uses to reject stale changes when one is accepted, and
accepted(ActionRequestType), the active subscriptions and delegations.
Requests are saved whole and replaced whole; store them as
ActionRequest::toStorageJsonLd() (every property this package knows,
whatever version partners negotiate) and read them back with fromJsonLd().
SubscriptionStore¶
Two directions. subscribersOf(Iri, types, now) answers who must be notified
about an object; the in-memory version derives it from
ActionRequestStore::accepted(Subscription), so it works over any request
store. offer(Subscription), withdraw(Subscription) and
offered(topicType, topic) are the host's own interests, answered to
GET /subscriptions when a publisher asks.
AccessDelegationStore¶
grant(Grant), grantsFor(agent, object), revokeFrom(requestIri) and
eraseFor(object). The access policy consults it; accepted
AccessDelegationRequests write to it, and so do the host's own grants and
the public ones (GrantAccessPolicy::EVERYONE as the agent).
Authentication and authorisation¶
Authenticator¶
authenticate(ServerRequestInterface): ?Agent. Null means 401. The SDK ships
Auth\JwtAuthenticator for RS256 bearer tokens; a host with its own token
scheme implements the interface directly.
AccessPolicy¶
decide(Agent, Action, ?Iri): Decision, where Action is one of the spec's
four permissions (GET_LOGISTICS_OBJECT, PATCH_LOGISTICS_OBJECT,
POST_LOGISTICS_EVENT, GET_LOGISTICS_EVENT) or one of the server's
internal actions (read audit trail, create object, decide an action request,
read or revoke someone else's action request). Decision::Allow,
Decision::Forbid (403) or Decision::Hide (404, so existence is not
confirmed).
This is where a host encodes its own rules ("a forwarder sees the shipments
routed to it"). Server\GrantAccessPolicy is the policy most hosts run:
internal agents (constructor argument), every grant read from the
AccessDelegationStore including public ones, deny by default. Wrap or
replace it for rules the grant model cannot express.
Notifications¶
NotificationOutbox¶
enqueue(OutboundNotification). The server never sends HTTP itself: it queues
{id, recipient, notification, createdAt} and the host delivers, through a
queue worker and its own egress rules, typically with the SDK's client.
OutboundNotification::suggestedEndpoint() derives the recipient's
/notifications URL from its agent URI; a host may know better.
enqueue() runs inside the unit of work, next to the writes it announces.
Hand the row to your queue only once that unit has committed (a database
after-commit hook, not the enqueue call), and let a rolled-back unit leave no
job behind. Delivery is at least once whatever the host does, so keep the
notification id with the delivery row, never send one id twice as a new
notification, and expect recipients to deduplicate on it (the SDK client
sends it as Idempotency-Key).
Transactions¶
UnitOfWork¶
run(callable): mixed. A host with persistent stores must bind its
database transaction here. The default, IdentityUnitOfWork, runs the work
directly, which is right only for the in-memory stores; with anything else a
failed operation leaves behind what it had already written, and Services
logs a warning when it sees that combination. The server runs every mutating
request through the unit, and DataHolder and ActionRequests run each of
their operations through it, so everything an operation writes (a revision,
the request, rejected siblings, grants, queued notifications) stands or falls
together. Nested calls join the outer unit (use a depth counter or
savepoints). Independently of the unit, every decision on an action request
is a compare-and-set on its status that happens before any side effect, so a
decision that lost a race writes nothing even without a transaction.
The warning is decided by the Spi\Volatile marker, not by class name: the
in-memory stores implement it, and a decorator you put around one (a
recording double, a queue in front of a test outbox) should implement it too
to stay quiet. ServerBuilder::check($services) returns the same findings as
a list of sentences, for a status page or health check where operators look
(Drupal's hook_requirements, Laravel's about).
Identifiers¶
Model\IriMinter (mint(localKey, types): Iri) decides the URIs of objects
the host publishes; UuidIriMinter is the default. Model\EmbeddedIdMinter
decides the ids of embedded nodes; the default is the spec's
internal:<uuid5>. Spi\IdGenerator (next(): string) provides the ids of
action requests, events and outbound notifications; UuidIdGenerator is the
default and takes an injectable byte source for deterministic tests.
Clock, events, logging¶
The server takes a PSR-20 clock (Server\SystemClock if you have none), a
PSR-14 dispatcher and an optional PSR-3 logger. Timestamps are written with
millisecond precision; SystemClock ticks in milliseconds so a stored
document carries exactly the instant the server computed. A clock of your
own should truncate the same way. It raises
LogisticsObjectCreated, LogisticsObjectRevised, LogisticsEventReceived,
ActionRequestCreated, ActionRequestStatusChanged and
NotificationReceived; a wrapper maps them to its own event system.
Listeners run inside the unit of work: a listener that throws fails the
operation and rolls it back with everything else. Listeners that do I/O of
their own should queue it for after the commit rather than perform it in
place. Every event reports a state whose consequences are already written:
ActionRequestStatusChanged fires after the grants, the revision or the
revocation it decided are in place (and after LogisticsObjectRevised for an
accepted change), so a listener that reads the policy or the object sees the
world after the decision. It fires once per decision, with the status the
request had before it; a change that was accepted and then failed to apply
reports Failed from Pending, although the store recorded Accepted in
between (a store validating transitions itself must allow Accepted to
Failed).
Wiring¶
Server\Services is the bag of all of the above. ServerBuilder::build(Services)
returns the PSR-15 handler, ServerBuilder::check(Services) the wiring
findings worth showing an operator; InMemoryServer is a worked example of
the wiring.
Testing your implementation¶
Testing\Contract ships the behaviour every store must have as PHPUnit
tests, in two forms per interface. The abstract classes
(LogisticsObjectStoreContract, LogisticsEventStoreContract,
ActionRequestStoreContract, SubscriptionStoreContract,
AccessDelegationStoreContract, NotificationOutboxContract) extend
PHPUnit's TestCase: extend one, return your store from its factory method,
and the tests that prove the in-memory stores prove yours. When your test
case must extend a framework base class instead (Laravel's Testbench,
Drupal's KernelTestBase), use the trait behind each class
(LogisticsObjectStoreContractTests and so on) in your own test case; it
carries the same tests and the same factory hook. The class is only the
trait on a bare TestCase, so the two cannot drift. Stores are expected to
start empty, and every event handed to a store carries cargo:eventDate: the
server refuses a posted event without one, and so does the checked builder.
Testing also holds the doubles the SDK's own
tests use: FixedClock (advance() and set()), HeaderAuthenticator,
RecordingDispatcher, RecordingUnitOfWork, FakeHttpClient,
InProcessHttpClient (the SDK client against a PSR-15 handler in one
process), ArrayCache and RacingActionRequestStore. The last one stages the
race every host must survive: wrap your request store, arm() it with a
request, and the next decision on that request either loses its
compare-and-set outright or first runs your callback (flip your own row to
the competing status there) and then loses for real; the SDK's own test of the
scenario uses it the same way. PHPUnit is
a suggested dependency; nothing else in the package needs it.