standard_audit 0.15.1 → 0.15.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d166172ff4fb5afad44ee41bdc696458000759d778d26978feba930a1f0f15d
4
- data.tar.gz: 04f546a81c899d026a38d5db3ae9ec18d0e8be7968bd6dc00fbb8bd521b01704
3
+ metadata.gz: 0f28d1e0742cc3754f99b25e1242c69c70f72cbf781d2fc696b3be326fccd74c
4
+ data.tar.gz: 06f1dfe7a30578b6f8df0d3a672996802963dcdf9c316af967042b5b8f02648b
5
5
  SHA512:
6
- metadata.gz: 0ff1c20d8dcb9b23d6b6aac9e5c25c9c64ab2a31a3117f5a646ae6018d99bca5bdc09fe8c11010ccfeab83231d63bbd7354872c9dafac249719d54e18da97dbd
7
- data.tar.gz: d546320a519c833a3015a01887fea2459d9fe83c936e417eaae240059c94ed1fab08666d71da0773229a475424edd04834c19396287a6454ba317d1f868301fc
6
+ metadata.gz: 32f665b055842c67a00bb099f4919ae230b0f304f7c493a2ad4b783fa83540099fc7cf2b2259e0c7d08d71024a87af6707548110813062ee795e137b123fa73f
7
+ data.tar.gz: 3a8a1dc2fc568ee1668eb1dcd06fdc00620cb33f202c6b535d955e2b826ca2df0cd08e5ce3f9a214dfdbe741dc1f0bbf6def9add32531d96dabf92b6db2212fe
data/CHANGELOG.md CHANGED
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.2] - 2026-10-30
11
+
12
+ ### Fixed
13
+ - `verify_chain` and `relink_checksums!` now find a parent that sorts **after**
14
+ its child: two concurrent writers, the parent committed first (so the child
15
+ read its checksum as the tip) but stamped `created_at` a few milliseconds
16
+ later. The backward recovery window never held it, so a sound row was
17
+ reported as `:digest_mismatch` and never relinked (3 rows on one production
18
+ host, 2026-10-30). A row that reproduces no earlier digest is now held for
19
+ the next `lookahead` rows (default 64, `lookahead: 0` disables) and counted
20
+ as `:recovered` / relinked when one of them is its parent.
21
+
10
22
  ## [0.15.1] - 2026-10-28
11
23
 
12
24
  ### Fixed
data/README.md CHANGED
@@ -1128,6 +1128,10 @@ They have no `previous_checksum`. Verification falls back to the preceding row
1128
1128
  in the walk and, failing that, searches back through the last `recovery_window`
1129
1129
  (default 256) digests for the one that reproduces the row's checksum — which
1130
1130
  recovers the true parent of a row that forked, **without re-signing anything**.
1131
+ A parent can also sort *after* its child (two concurrent writers: the parent
1132
+ committed first, so the child read its checksum as the tip, but was stamped a
1133
+ few milliseconds later), so a row that reproduces no earlier digest is held for
1134
+ the next `lookahead` rows (default 64) before it is reported.
1131
1135
  This does not weaken tamper detection: a row whose fields were altered
1132
1136
  reproduces no candidate's digest. Pass `strict: true` to skip the search and see
1133
1137
  every fork as a failure.
@@ -360,6 +360,13 @@ module StandardAudit
360
360
  # this window search, so a legacy row that was both forked and
361
361
  # reordered stays unverifiable.)
362
362
  #
363
+ # A parent can also sort AFTER its child: two concurrent writers, the
364
+ # parent committed first (so the child read its checksum as the tip) but
365
+ # stamped `created_at` a few milliseconds later. The backward window never
366
+ # holds it, so a row that reproduces no earlier digest is held for the next
367
+ # `lookahead` rows and counted as :recovered when one of them is its
368
+ # parent; only then is it a :digest_mismatch. (`lookahead: 0` disables it.)
369
+ #
363
370
  # The chain links across the cutover like anywhere else: the first
364
371
  # canonical row's parent is the last legacy row's stored checksum.
365
372
  #
@@ -379,7 +386,7 @@ module StandardAudit
379
386
  # `created_at`. Rows whose two timestamps disagree (a backdated
380
387
  # `occurred_at`) can leave a hole rather than a prefix, and a hole is
381
388
  # reported — truthfully, since rows really are missing.
382
- def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, strict: false,
389
+ def self.verify_chain(scope: nil, batch_size: 1000, recovery_window: 256, lookahead: 64, strict: false,
383
390
  key_order_search_limit: StandardAudit::Checksum::KeyOrderSearch::DEFAULT_LIMIT,
384
391
  fail_on_legacy_unverifiable: false)
385
392
  relation = scope ? where(scope_gid: scope.to_global_id.to_s) : all
@@ -395,6 +402,10 @@ module StandardAudit
395
402
  unverifiable = []
396
403
  failures = []
397
404
  window = []
405
+ # Rows whose parent may still be AHEAD in the walk (see `lookahead`):
406
+ # [{ record:, algorithm:, expected:, seen: }], resolved against each
407
+ # later row's checksum, given up after `lookahead` rows.
408
+ pending = []
398
409
  first_row = true
399
410
  pruned_parents = []
400
411
 
@@ -426,6 +437,17 @@ module StandardAudit
426
437
  next
427
438
  end
428
439
 
440
+ pending.delete_if do |p|
441
+ if walk_digester(p[:record], p[:algorithm]).call(record.checksum) == p[:record].checksum
442
+ recovered += 1
443
+ elsif (p[:seen] += 1) >= lookahead
444
+ failures << chain_failure(p[:record], expected: p[:expected], reason: :digest_mismatch)
445
+ else
446
+ next false
447
+ end
448
+ true
449
+ end
450
+
429
451
  declared = declared_parents ? record.previous_checksum : nil
430
452
 
431
453
  if record.anonymized?
@@ -465,6 +487,10 @@ module StandardAudit
465
487
  unverifiable << entry
466
488
  failures << entry if fail_on_legacy_unverifiable
467
489
  end
490
+ elsif declared.blank? && !strict && lookahead.positive?
491
+ # Its parent may sort AFTER it (committed first, stamped later by a
492
+ # concurrent writer); decided when the next `lookahead` rows pass.
493
+ pending << { record: record, algorithm: algorithm, expected: expected, seen: 0 }
468
494
  else
469
495
  failures << chain_failure(record, expected: expected, reason: :digest_mismatch)
470
496
  end
@@ -476,6 +502,8 @@ module StandardAudit
476
502
  window.shift if window.size > recovery_window
477
503
  end
478
504
 
505
+ pending.each { |p| failures << chain_failure(p[:record], expected: p[:expected], reason: :digest_mismatch) }
506
+
479
507
  {
480
508
  valid: failures.empty?,
481
509
  verified: verified,
@@ -499,7 +527,7 @@ module StandardAudit
499
527
  # the row has held since it was written. Rows whose parent cannot be
500
528
  # reproduced are left untouched and counted in :unresolved — they are the
501
529
  # rows verification should keep reporting.
502
- def self.relink_checksums!(batch_size: 1000, recovery_window: 256)
530
+ def self.relink_checksums!(batch_size: 1000, recovery_window: 256, lookahead: 64)
503
531
  return { relinked: 0, unresolved: 0, skipped: 0 } unless chain_parent_column?
504
532
 
505
533
  previous_checksum = nil
@@ -507,6 +535,7 @@ module StandardAudit
507
535
  unresolved = 0
508
536
  skipped = 0
509
537
  window = []
538
+ pending = [] # rows whose parent may still be ahead (see verify_chain's `lookahead`)
510
539
 
511
540
  each_in_chain_order(all, batch_size: batch_size) do |record|
512
541
  if record.checksum.blank?
@@ -515,12 +544,28 @@ module StandardAudit
515
544
  next
516
545
  end
517
546
 
547
+ pending.delete_if do |p|
548
+ if walk_digester(p[:record], p[:algorithm]).call(record.checksum) == p[:record].checksum
549
+ p[:record].update_columns(previous_checksum: record.checksum)
550
+ relinked += 1
551
+ elsif (p[:seen] += 1) >= lookahead
552
+ unresolved += 1
553
+ else
554
+ next false
555
+ end
556
+ true
557
+ end
558
+
518
559
  found = resolve_parent(record, previous_checksum, window) if record.previous_checksum.blank?
519
560
 
520
561
  if record.previous_checksum.present?
521
562
  skipped += 1
522
563
  elsif found.nil?
523
- unresolved += 1
564
+ if lookahead.positive?
565
+ pending << { record: record, algorithm: StandardAudit::Checksum.algorithm_for(record.created_at), seen: 0 }
566
+ else
567
+ unresolved += 1
568
+ end
524
569
  elsif found.first.present?
525
570
  record.update_columns(previous_checksum: found.first)
526
571
  relinked += 1
@@ -534,6 +579,7 @@ module StandardAudit
534
579
  window.shift if window.size > recovery_window
535
580
  end
536
581
 
582
+ unresolved += pending.size
537
583
  { relinked: relinked, unresolved: unresolved, skipped: skipped }
538
584
  end
539
585
 
@@ -1,3 +1,3 @@
1
1
  module StandardAudit
2
- VERSION = "0.15.1"
2
+ VERSION = "0.15.2"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: standard_audit
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.15.1
4
+ version: 0.15.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jaryl Sim