Class: Reeve::Audit::Recorder

Inherits:
Object
  • Object
show all
Defined in:
lib/reeve/audit/recorder.rb

Overview

The one write path into the ledger (FR-008).

The envelope calls this from an ensure block and expects it to raise on failure — Constitution II makes a failed write a failed call unless the host has opted into a degraded mode. Everything here is therefore synchronous: no queue, no thread, no ActiveJob. An asynchronous ledger cannot be relied on and would make FR-012 unenforceable.

The insert runs in requires_new: true, which is what makes the trace survive a tool body that opens a transaction and rolls it back (R5) — the case this was built for, and the one it does solve.

What it does not do, corrected after review: requires_new is a SAVEPOINT, not an independent transaction. If the host has already opened a transaction around the invocation — a controller or middleware that wraps each request, or a test suite using transactional fixtures — the savepoint is released into that transaction, and a later rollback takes the ledger row with it. The write reports success and the envelope has no way to learn otherwise, so the invocation returns records with no surviving trace. The comment here previously claimed independence outright; it did not have it.

A genuinely independent write needs a second connection, and that is not portable: on SQLite the enclosing transaction holds the write lock, so a second connection blocks until it times out. Rather than fail every call on the databases where isolation is impossible, the recorder detects the enclosing transaction and warns that the guarantee is suspended for that call. A host that needs durability under a wrapping transaction supplies its own audit_recorder — writing to a separate connection, a queue, or an append-only log — which is what that setting is for.

The other known limit, unchanged: on a single connection there is a narrow window where the tool's data commits and the ledger write then fails. The caller learns by exception, but the data change has already landed.

Constant Summary collapse

DETAIL_LIMIT =

Free-form text from a policy or an exception message. Capped rather than trusted: it is the one column whose length the host does not control.

1000

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(entry_class: Entry, config: nil) ⇒ Recorder

Returns a new instance of Recorder.



46
47
48
49
# File 'lib/reeve/audit/recorder.rb', line 46

def initialize(entry_class: Entry, config: nil)
  @entry_class = entry_class
  @config = config
end

Class Method Details

.record(attributes) ⇒ Object



42
43
44
# File 'lib/reeve/audit/recorder.rb', line 42

def self.record(attributes)
  new.record(attributes)
end

Instance Method Details

#record(attributes) ⇒ Object

Returns the entry. Raises if the row could not be written, so the envelope can fail the invocation.



53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/reeve/audit/recorder.rb', line 53

def record(attributes)
  row = row_for(attributes)
  warn_about_enclosing_transaction(row[:invocation_id])

  entry_class.transaction(requires_new: true) do
    entry_class.create!(row)
  end
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid => e
  # A replayed invocation is the same invocation. One row per invocation_id means
  # a retry is a no-op insert, never a second row (contracts/audit-entry.md).
  existing = entry_class.find_by(invocation_id: row[:invocation_id])
  raise e if existing.nil?

  existing
end