@@ -66,7 +66,7 @@ public async Task<ResultBox<string>> GetLatestSortableUniqueIdAsync()
6666 }
6767 }
6868
69- public async Task < ResultBox < TagWriteReservation > > MakeReservationAsync ( string lastSortableUniqueId )
69+ public async Task < ResultBox < TagWriteReservation > > MakeReservationAsync ( string ? lastSortableUniqueId )
7070 {
7171 // Ensure catch-up is completed before acquiring lock
7272 await EnsureCatchUpCompletedAsync ( ) ;
@@ -85,9 +85,16 @@ public async Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string la
8585 new Exception ( $ "Tag { await GetTagActorIdAsync ( ) } is currently reserved") ) ;
8686 }
8787
88- // SEK-G19: EXACT-MATCH optimistic-concurrency check after null/empty NORMALIZATION, in-lock and post-catch-up. An
88+ // SEK-G30: null means the command never observed this tag. Keep the complete reservation lifecycle, but do
89+ // not compare, refresh, or adopt a version. Empty remains G19 AssertEmpty and non-empty remains ExactMatch.
90+ if ( lastSortableUniqueId is null )
91+ {
92+ return ResultBox . FromValue ( await CreateReservationAsync ( ) ) ;
93+ }
94+
95+ // SEK-G19: EXACT-MATCH optimistic-concurrency check after the SEK-G30 null branch, in-lock and post-catch-up. An
8996 // empty caller version means "I expect this tag to be EMPTY" (a first write) — NOT "skip the check". Comparing
90- // the normalized expected against the normalized current (both null/empty collapse to "") covers all five
97+ // empty expected/current values normalized to "" covers all five
9198 // classes: empty/empty pass (first write on an empty tag); empty/non-empty CONFLICT (a second first-write against
9299 // a tag that already has committed state — the #1085 hole); non-empty/empty CONFLICT (an update expecting a
93100 // version the tag never had — the secondary hole); non-empty mismatch CONFLICT; non-empty match pass. The
@@ -131,24 +138,26 @@ public async Task<ResultBox<TagWriteReservation>> MakeReservationAsync(string la
131138 _latestSortableUniqueId = lastSortableUniqueId ;
132139 }
133140
134- // Create new reservation
135- var reservationCode = Guid . NewGuid ( ) . ToString ( ) ;
136- var expiredUtc = DateTime . UtcNow . AddSeconds ( _options . CancellationWindowSeconds ) ;
137- var reservation = new TagWriteReservation (
138- reservationCode ,
139- expiredUtc . ToString ( "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'" ) ,
140- await GetTagActorIdAsync ( ) ) ;
141-
142- _activeReservations [ reservationCode ] = reservation ;
143-
144- return ResultBox . FromValue ( reservation ) ;
141+ return ResultBox . FromValue ( await CreateReservationAsync ( ) ) ;
145142 }
146143 finally
147144 {
148145 _reservationLock . Release ( ) ;
149146 }
150147 }
151148
149+ private async Task < TagWriteReservation > CreateReservationAsync ( )
150+ {
151+ var reservationCode = Guid . NewGuid ( ) . ToString ( ) ;
152+ var expiredUtc = DateTime . UtcNow . AddSeconds ( _options . CancellationWindowSeconds ) ;
153+ var reservation = new TagWriteReservation (
154+ reservationCode ,
155+ expiredUtc . ToString ( "yyyy-MM-dd'T'HH:mm:ss.fffffff'Z'" ) ,
156+ await GetTagActorIdAsync ( ) ) ;
157+ _activeReservations [ reservationCode ] = reservation ;
158+ return reservation ;
159+ }
160+
152161 /// <summary>
153162 /// Performs the bounded SEK-G22 authoritative re-check while <see cref="_reservationLock" /> is already held.
154163 /// This method deliberately does not call the catch-up path or acquire the reservation lock.
0 commit comments