@@ -26,12 +26,88 @@ const { Type, Field } = protobuf;
2626 * byte-for-byte.
2727 */
2828
29+ /**
30+ * Declinable reasons a core node can return on `/dkg/10.0.0/storage-ack`
31+ * instead of a signed ACK. These are situations where the core
32+ * legitimately cannot produce an ACK for THIS PEER right now — a
33+ * well-formed publish request that this specific core just can't
34+ * satisfy.
35+ *
36+ * Codes split into two classes that the publisher treats differently:
37+ *
38+ * - **Transient** ({@link TRANSIENT_STORAGE_ACK_DECLINE_CODES}):
39+ * the local condition is expected to clear on its own (e.g. SWM is
40+ * catching up via gossip). The publisher retries the same peer with
41+ * the existing transport backoff before giving up — keeps quorum
42+ * reachable when replication is just slightly behind the publish.
43+ *
44+ * - **Permanent** (every other code):
45+ * the condition will not change on a fast retry (e.g. the operational
46+ * signer was rotated off-chain). The publisher deselects this peer
47+ * for THIS request and moves on to others; the reason is surfaced
48+ * in the final error if quorum still fails.
49+ *
50+ * Malformed-request errors are NOT declines and do NOT belong here.
51+ * The handler keeps `throw`ing on those so the publisher sees them as
52+ * stream errors with the original message and surfaces them to the
53+ * caller immediately rather than fanning out to every core looking
54+ * for a different answer.
55+ *
56+ * String values are part of the wire format: they are populated into
57+ * `StorageACK.declineCode` and surfaced in publisher logs / error
58+ * messages. Keep them stable across releases. Adding a new code is a
59+ * non-breaking change (older publishers see it as the catch-all
60+ * "unknown decline" path); renaming or removing one IS breaking.
61+ */
62+ export const STORAGE_ACK_DECLINE_CODES = {
63+ /** SWM CONSTRUCT returned no quads for the request. */
64+ NO_DATA_IN_SWM : 'NO_DATA_IN_SWM' ,
65+ /** SWM has data but its merkle root does not match the publisher's. */
66+ MERKLE_MISMATCH_IN_SWM : 'MERKLE_MISMATCH_IN_SWM' ,
67+ /** Operational signer was just removed / rotated off-chain. */
68+ SIGNER_NOT_REGISTERED : 'SIGNER_NOT_REGISTERED' ,
69+ } as const ;
70+
71+ export type StorageACKDeclineCode =
72+ ( typeof STORAGE_ACK_DECLINE_CODES ) [ keyof typeof STORAGE_ACK_DECLINE_CODES ] ;
73+
74+ /**
75+ * Decline codes whose root cause is expected to clear on its own
76+ * (typically SWM replication catching up via gossip). The publisher
77+ * retries these against the same peer through the normal transport
78+ * backoff before giving up, so a peer that would have ACKed seconds
79+ * later still contributes to quorum.
80+ *
81+ * Membership of this set is part of the protocol contract between
82+ * publisher and core — promoting / demoting a code is a behavior
83+ * change, not a wire change.
84+ */
85+ export const TRANSIENT_STORAGE_ACK_DECLINE_CODES : ReadonlySet < string > = new Set < string > ( [
86+ STORAGE_ACK_DECLINE_CODES . NO_DATA_IN_SWM ,
87+ STORAGE_ACK_DECLINE_CODES . MERKLE_MISMATCH_IN_SWM ,
88+ ] ) ;
89+
90+ /** True iff `code` names a decline the publisher should retry rather than treat as permanent. */
91+ export function isTransientStorageACKDeclineCode ( code : string | undefined ) : boolean {
92+ return typeof code === 'string' && TRANSIENT_STORAGE_ACK_DECLINE_CODES . has ( code ) ;
93+ }
94+
95+ /**
96+ * Wire schema. Fields 1–5 are the original ACK shape; fields 6–7 carry
97+ * a decline payload. Two optional strings (rather than a `oneof`) keep
98+ * the change strictly additive: old encoders never set the new fields,
99+ * old decoders silently ignore them, so cross-version traffic is
100+ * unchanged. New decoders inspect `declineCode` first — when it is
101+ * non-empty the message is a decline and the ACK fields are unset.
102+ */
29103export const StorageACKSchema = new Type ( 'StorageACK' )
30104 . add ( new Field ( 'merkleRoot' , 1 , 'bytes' ) )
31105 . add ( new Field ( 'coreNodeSignatureR' , 2 , 'bytes' ) )
32106 . add ( new Field ( 'coreNodeSignatureVS' , 3 , 'bytes' ) )
33107 . add ( new Field ( 'contextGraphId' , 4 , 'string' ) )
34- . add ( new Field ( 'nodeIdentityId' , 5 , 'uint64' ) ) ;
108+ . add ( new Field ( 'nodeIdentityId' , 5 , 'uint64' ) )
109+ . add ( new Field ( 'declineCode' , 6 , 'string' ) )
110+ . add ( new Field ( 'declineMessage' , 7 , 'string' ) ) ;
35111
36112type Long = { low : number ; high : number ; unsigned : boolean } ;
37113
@@ -41,6 +117,31 @@ export interface StorageACKMsg {
41117 coreNodeSignatureVS : Uint8Array ;
42118 contextGraphId : string ;
43119 nodeIdentityId : number | Long ;
120+ /**
121+ * When non-empty, this message is a decline rather than a signed ACK
122+ * — see {@link STORAGE_ACK_DECLINE_CODES}. Old senders never set this
123+ * field; old receivers ignore it. New receivers MUST check this
124+ * before treating the message as an ACK (signature/merkleRoot are
125+ * unset on declines).
126+ */
127+ declineCode ?: string ;
128+ /**
129+ * Human-readable reason that accompanies `declineCode`. Surfaced into
130+ * publisher logs and the final `storage_ack_insufficient` error so
131+ * operators can diagnose hosting / replication issues without having
132+ * to ssh into individual cores.
133+ */
134+ declineMessage ?: string ;
135+ }
136+
137+ /**
138+ * Convenience: returns true iff the message is a decline (i.e.
139+ * `declineCode` is set to a non-empty string). Keeps callers from
140+ * having to remember the empty-string-as-undefined idiom that
141+ * protobufjs uses for unset string fields.
142+ */
143+ export function isStorageACKDecline ( msg : StorageACKMsg ) : boolean {
144+ return typeof msg . declineCode === 'string' && msg . declineCode . length > 0 ;
44145}
45146
46147export function encodeStorageACK ( msg : StorageACKMsg ) : Uint8Array {
0 commit comments