@@ -175,6 +175,10 @@ export async function addEvent({cel, event}) {
175175export async function read ( { cel, trustedWitnesses = [ ] , versionTime = null } ) {
176176 const errors = [ ] ;
177177 let currentDidDocument = null ;
178+ // effective heartbeat array, updated independently of currentDidDocument:
179+ // heartbeat events put rotated hashes on the event itself rather than in the
180+ // DID document, so we must track this state separately
181+ let currentHeartbeat = null ;
178182 // latest witness timestamp for the previous log entry, used for heartbeat
179183 // frequency checks at each subsequent entry boundary
180184 let prevEntryWitnessTime = null ;
@@ -274,12 +278,23 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
274278 // in effect during the gap leading into this entry, not any new frequency
275279 // introduced by this entry's update.
276280 const prevDidDocument = currentDidDocument ;
281+ // Snapshot effective heartbeat array before this entry updates it.
282+ const prevHeartbeat = currentHeartbeat ;
277283
278284 // Track the current DID document for key lookup on stateless events
279285 if ( event . operation ?. data ) {
280286 currentDidDocument = event . operation . data ;
281287 }
282288
289+ // Update the effective heartbeat array. For heartbeat events the rotated
290+ // hashes live on the event itself; for create/update they come from the
291+ // DID document. This must stay in sync with the rotation check below.
292+ if ( event . heartbeat ) {
293+ currentHeartbeat = event . heartbeat ;
294+ } else if ( currentDidDocument ?. heartbeat ) {
295+ currentHeartbeat = currentDidDocument . heartbeat ;
296+ }
297+
283298 // Mark the DID as deactivated after processing this entry so that any
284299 // subsequent entries are rejected at the top of the next iteration.
285300 if ( event . operation ?. type === 'deactivate' ) {
@@ -353,22 +368,23 @@ export async function read({cel, trustedWitnesses = [], versionTime = null}) {
353368 }
354369
355370 // 5. If the operation was signed by a heartbeat key, verify that the new
356- // DID document no longer contains that heartbeat hash (it must be rotated
357- // out) and contains at least one new heartbeat hash.
358- if ( opProof && currentDidDocument ) {
371+ // heartbeat array no longer contains the used hash (rotated out) and
372+ // contains at least one new hash. The new array comes from event.heartbeat
373+ // for heartbeat events, or from the DID document for update events.
374+ if ( opProof ) {
359375 const vmRef = opProof . verificationMethod ;
360376 if ( vmRef ?. startsWith ( 'did:key:' ) ) {
361377 const didKeyId = vmRef . split ( '#' ) [ 0 ] ;
362378 const usedHash = await hashDidKey ( didKeyId ) ;
363- const prevHeartbeat = prevDidDocument ?. heartbeat ?? [ ] ;
364- const newHeartbeat = currentDidDocument ?. heartbeat ?? [ ] ;
365- if ( prevHeartbeat . includes ( usedHash ) ) {
379+ const oldHeartbeat = prevHeartbeat ?? prevDidDocument ?. heartbeat ?? [ ] ;
380+ const newHeartbeat = currentHeartbeat ?? [ ] ;
381+ if ( oldHeartbeat . includes ( usedHash ) ) {
366382 if ( newHeartbeat . includes ( usedHash ) ) {
367383 errors . push (
368384 `entry ${ i } : heartbeat key used without rotating its hash - ` +
369385 `${ usedHash } must be removed from heartbeat[]` ) ;
370386 }
371- if ( newHeartbeat . length < prevHeartbeat . length ) {
387+ if ( newHeartbeat . length < oldHeartbeat . length ) {
372388 errors . push (
373389 `entry ${ i } : heartbeat key rotation must add a new heartbeat ` +
374390 `hash to replace the consumed one` ) ;
0 commit comments