ReactorProtocol
in package
The reactor wire protocol — CONTRACT.md §22.2, §22.3, §22.4.
Both directions are signed with the same §8 v2 primitives and the same tenant subkey: the server signs the event, the reactor signs the reply. A reply is an instruction to change a token or refuse a login, so an unsigned reply is not a weak reply — it is not a reply at all, and the server discards it as though the reactor had never answered.
THE ONE CANONICALIZATION DIFFERENCE FROM §8. On a reactor event and a reactor
reply, hmac_signature is serialized as JSON null inside the signed bytes.
It is NOT omitted, which is what §8's own two message types (AuthzRequest,
AuditEventMessage — see Hmac) do. Getting this wrong
produces a MAC that never verifies in either direction, so the §22.13 vectors
carry canonical_signed_json for every message and
tests/ReactorVectorsTest.php asserts against them byte-for-byte rather than
against anyone's memory of this paragraph.
THE PHP-SPECIFIC TRAPS, all of which this class encodes once:
json_encode()escapes forward slashes and non-ASCII by default andserde_jsonescapes neither, so every call here passesJSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE(the same Pitfall 1 the §8Hmacclass documents).- Bodies are decoded to
stdClass, not to associative arrays. An empty JSON object decoded into an array re-encodes as[]rather than}, which silently changes the canonical bytes of any event whosepayloadis empty. - A reply's
patchis written through astdClassand key-sorted withSORT_STRING, because the server'sBTreeMap<String, String>emits its keys in byte order while a PHP array emits them in insertion order.
Table of Contents
Constants
- CHAIN_PATCH_KEY : mixed = '_reactor_patch'
- The payload key under which the server inserts the patch accumulated by earlier reactors in the chain (§22.3), so a later reactor decides against the state that will actually be committed.
- FRESHNESS_SKEW_SECONDS : mixed = \Axiam\Sdk\Amqp\ReplayGuard::DEFAULT_SKEW_SECONDS
- The ±freshness window, in seconds, applied to `issued_at` in **both** directions. A future timestamp is not "extra fresh", it is the shape of a captured message held for later.
- KEY_VERSION : mixed = 2
- The §8 v2 key version both directions carry. A body carrying less than this is refused **before anything else about it is considered** — including its signature (§22.2, §22.4 row 4).
Methods
- buildReply() : string
- Renders and signs the reply for one handler answer (§22.2, §22.4).
- canonicalize() : string
- Serializes `$message` to the exact bytes HMAC runs over.
- decodeEvent() : ReactorEvent
- Parses and fully verifies one delivery body, in the order §22.3 fixes: reject `key_version < 2`, verify the MAC, check freshness, check the nonce.
- sign() : string
- HMAC-SHA256 of `$canonical` under the tenant's HKDF-derived AMQP subkey (§8.1, §22.2), hex-encoded. There is no second key and no asymmetric variant in v1.
- verify() : bool
- Constant-time verification of a hex-encoded MAC. Never throws: a non-hex or wrong-length signature verifies as false.
Constants
CHAIN_PATCH_KEY
The payload key under which the server inserts the patch accumulated by earlier reactors in the chain (§22.3), so a later reactor decides against the state that will actually be committed.
public
mixed
CHAIN_PATCH_KEY
= '_reactor_patch'
FRESHNESS_SKEW_SECONDS
The ±freshness window, in seconds, applied to `issued_at` in **both** directions. A future timestamp is not "extra fresh", it is the shape of a captured message held for later.
public
mixed
FRESHNESS_SKEW_SECONDS
= \Axiam\Sdk\Amqp\ReplayGuard::DEFAULT_SKEW_SECONDS
KEY_VERSION
The §8 v2 key version both directions carry. A body carrying less than this is refused **before anything else about it is considered** — including its signature (§22.2, §22.4 row 4).
public
mixed
KEY_VERSION
= 2
Methods
buildReply()
Renders and signs the reply for one handler answer (§22.2, §22.4).
public
static buildReply(string $signingKey, ReactorEvent $event, ReactorAnswer $answer, string $nonce, int $now) : string
Field order is the server's struct declaration order:
correlation_id, tenant_id, event, decision, reason (omitted when
absent), patch (omitted when absent), require_mfa (omitted when
false), key_version, nonce, issued_at, hmac_signature (null
while signing).
The three conditional omissions are load-bearing: a reply that serializes
"require_mfa": false rather than omitting it produces different canonical
bytes and therefore a different MAC. An SDK must reproduce the omission
rule, not merely the values.
Two answers are refused here rather than put on the wire, and each raises
ReactorRejection so the caller publishes nothing and the
registration's failure_policy decides:
require_mfaon any event other thanlogin.post_auth. §22.13 permits rejecting this client-side or sending it and surfacing the server's rejection; rejecting names the author's mistake at the place it was made.- a
mutateanswer with an empty patch, which the server rejects asmalformed_mutation.
It does NOT refuse a patch key outside the event's allow-list. That is the one case where sending the wrong thing is required: the server names the offending key in its audit record, and filtering it out here would hide the mistake from everyone (§22.4 rule 1).
Parameters
- $signingKey : string
- $event : ReactorEvent
- $answer : ReactorAnswer
- $nonce : string
-
A FRESH UUIDv4 per reply. The server keeps no durable nonce-dedup store for replies — its protection is the freshness window plus the
correlation_idbinding — but the nonce is inside the signed bytes, and a unique one is the only thing that keeps two replies from being byte-identical. - $now : int
-
The instant to stamp
issued_atwith.
Tags
Return values
stringcanonicalize()
Serializes `$message` to the exact bytes HMAC runs over.
public
static canonicalize(stdClass $message) : string
The caller is responsible for property order — PHP preserves the order properties were assigned in, which is how the server's serde field-declaration order is reproduced without a sorting helper.
Parameters
- $message : stdClass
Return values
stringdecodeEvent()
Parses and fully verifies one delivery body, in the order §22.3 fixes: reject `key_version < 2`, verify the MAC, check freshness, check the nonce.
public
static decodeEvent(string $signingKey, string $body, string $expectedTenantId, ReplayGuard $guard, int $now) : ReactorEvent
Only then is the payload decoded and handed on.
Identity and registry membership are checked after the MAC: neither is cryptography, and spending them on unauthenticated bytes would tell an unauthenticated party what this reactor accepts.
Parameters
- $signingKey : string
-
The tenant's HKDF-derived AMQP subkey.
- $body : string
-
The raw delivery bytes, exactly as received.
- $expectedTenantId : string
-
This reactor's configured tenant. An event naming another one is refused outright.
- $guard : ReplayGuard
-
The shared §8 v2 freshness + nonce gate. ONE instance must serve every delivery — a fresh guard per message would defeat replay dedup entirely.
- $now : int
-
The instant to measure freshness and the dispatch deadline against; must be the same clock
$guardreads.
Tags
Return values
ReactorEventsign()
HMAC-SHA256 of `$canonical` under the tenant's HKDF-derived AMQP subkey (§8.1, §22.2), hex-encoded. There is no second key and no asymmetric variant in v1.
public
static sign(string $signingKey, string $canonical) : string
Parameters
- $signingKey : string
- $canonical : string
Return values
stringverify()
Constant-time verification of a hex-encoded MAC. Never throws: a non-hex or wrong-length signature verifies as false.
public
static verify(string $signingKey, string $canonical, string $signatureHex) : bool
Parameters
- $signingKey : string
- $canonical : string
- $signatureHex : string