The audit rules as a contract and a query A ledger line contract and a forged-ledger audit
Refuse a ledger line with a missing field or an unknown event, then see why that contract accepts four forged ledgers and how a query that re-derives the plan names every edit, every deletion and every damaged line with the same counts as a Rust auditor.
Lab download. Starter is the tree you edit. Worked answer is a separate file; open it after the starter passes. Nickel 1.18 and Nushell 0.116. The reference tree's jq-audit.sh also needs jq 1.7. All lab requirements.
O-Data · A · arc: self-healing-edge · langs: nickel, nushell, jq · DOP-4.2
By Evan Eugene Pincham.
Concept
Picture a deploy ledger, the file a deployment tool writes to record what it did to a service. Every line is valid JSON. Every line has a numeric timestamp, a known event name and a version string. A format check passes it without a warning. One line reads {"event":"confirmed","restarts":1,"ts":12000,"version":"v2"}, which says release v2 was declared healthy at 12 seconds. The release was only put into service at 1 second, and the tool's own rule is that a release has to survive a 30-second probation first. The line is well formed and false.
A contract written in Nickel reads that line, finds nothing wrong, and accepts it. A query written in Nushell reads the same ledger next to the plan that produced it, and rejects it. The rest of the page shows the two programs, and what each can and cannot see.
The system being audited. A deployer promotes a new release, watches it through a probation called the soak window (30 000 ms here), and allows it a restart budget of 2: the deployer restarts the release after its first two crashes, and a third crash inside the window means the deployer gives up and rolls back. If the window closes with the release still alive, the release is confirmed. A rollback always goes to the last release that was confirmed, or to the release that was already serving when everything began.
Two files. Both are JSONL, meaning one JSON object per line.
- The plan is the deployer's input, a timeline of what happened to the service. Its events are
serving(the release already live when the timeline starts, the baseline),promoted(a new release is put into service; this line also carries apreviousfield that the auditor never reads),exited(the release's process died) andtick(a clock marker: time passed, nothing else happened). - The ledger is the deployer's output, a list of claims about what it did. Its events are
restarting(it restarted the release after an exit, attempt n of a maximum),rolled_back,confirmedandrestarted_post_soak.
Contract versus query. A contract is a declaration of what a single record may look like: which fields exist and what types they have. Nickel, a configuration language, enforces contracts: nickel export evaluates a file and fails with a message when a value violates its contract. A query is a program that reads data and computes something from it. Nushell, a shell where pipelines carry structured tables rather than text, is the query language here. A contract looks at one line at a time and knows nothing about the plan. The false confirmed line above is invisible to it, because nothing in the line itself is wrong. Detecting the lie requires the plan.
Why re-derive. Treat the ledger as an event log. In Designing Data-Intensive Applications, Kleppmann describes deriving current state from an event log by a transformation that "should be deterministic, so that you can run it again and derive the same application state from the event log." The deployer's decisions follow that pattern: the same plan must always produce the same confirmations and rollbacks. So the auditor runs the decision rules again over the plan and compares the result with the ledger, in both directions:
- Claims without support. A ledger line the plan does not justify: an early
confirmed, a rollback to the wrong release. These are edits. - Derived facts without claims. An outcome the plan implies but the ledger never states. These are deletions. A deleted line has no line number, so such a violation prints
line=noneand names the release instead.
Kleppmann's accounting example explains why the second direction is worth the trouble: in an append-only ledger a mistake is corrected by adding an entry, and the incorrect entry stays "because it might be important for auditing reasons". A ledger from which lines silently disappear has broken that bargain, and no check of the lines that remain will notice. The CloudWatch Logs documentation says of its filters: "Filter patterns only return the log events that contain the terms you define". A filter can only match lines that exist, so it has nothing to say about a line that was deleted.
What this audit is not. It is a CPU-only program over fixture files: no clock, no processes, no cluster. It is not tamper-proof storage. Someone who edits the plan and the ledger consistently, so that they still agree with each other, is not caught. What it catches is a ledger that contradicts the plan it claims to describe. The exam objective behind the exercise is AWS DevOps Engineer Professional (DOP-C02) Task Statement 4.2, "Audit, monitor, and analyze logs and metrics to detect issues".
Worked example
The worked example is a deployer's plan and ledger, checked from two sides. It touches two fields. For operations, the ledger is the deployer's output and "was this release really confirmed?" is a question the ledger cannot answer alone. For security, an edited or deleted ledger line is a forged history, so the check has to read meaning and has to notice omissions as well as edits. The contract (Part 1) judges shape; the query (Part 2) re-derives meaning; Part 3 covers a ledger that is damaged rather than forged.
The three honest cases
The lab ships three plans, with the ledgers the deployer wrote for them.
flap is a release that keeps crashing:
{"ts":0,"event":"serving","version":"v1"}
{"ts":1000,"event":"promoted","version":"v2","previous":"v1"}
{"ts":3000,"event":"exited","version":"v2"}
{"ts":6000,"event":"exited","version":"v2"}
{"ts":9000,"event":"exited","version":"v2"}
v1 is serving, v2 is promoted at 1 000 ms and exits at 3, 6 and 9 seconds. The third exit exceeds the budget of 2, so the honest ledger has two restarting lines and a rolled_back to v1 at 9 000:
{"attempt":1,"event":"restarting","max":2,"ts":3000,"version":"v2"}
{"attempt":2,"event":"restarting","max":2,"ts":6000,"version":"v2"}
{"event":"rolled_back","exits_in_window":3,"from":"v2","max":2,"reason":"restart_budget_exceeded","to":"v1","ts":9000,"version":"v2","window_ms":30000}
stable has one crash and then survives. The tick at 31 000 is the first plan event at or past 1 000 + 30 000, so it closes the window:
{"ts":0,"event":"serving","version":"v1"}
{"ts":1000,"event":"promoted","version":"v2","previous":"v1"}
{"ts":3000,"event":"exited","version":"v2"}
{"ts":31000,"event":"tick"}
Its ledger is one restarting line and {"event":"confirmed","restarts":1,"ts":31000,"version":"v2"}.
chain rolls v2 back, then promotes v3 with a stale previous of v2 and rolls v3 back too. v2 was never confirmed, so v3's rollback target is v1, not v2. Its ledger has six lines: for v2, two restarting lines (at 2 000 and 3 000) and a rolled_back to v1 at 4 000; for v3, two restarting lines (at 52 000 and 53 000) and a rolled_back to v1 at 54 000.
Part 1: the contract and what it cannot see
ledger-line.ncl defines the contract for one line:
# ledger-line.ncl: the contract every ledger line must satisfy.
# A line needs a whole-number ts, a non-empty string version, one of four known
# events, and the fields that event always carries (a confirmed line carries restarts).
let is_whole = fun n => std.is_number n && std.number.is_integer n && n >= 0 in
let needs = {
restarting = ["attempt", "max"],
rolled_back = ["from", "to", "reason", "exits_in_window", "max", "window_ms"],
confirmed = ["restarts"],
restarted_post_soak = [],
} in
let counts = ["attempt", "max", "restarts", "exits_in_window", "window_ms"] in
let line_contract = std.contract.from_validator (fun line =>
if !(std.is_record line) then
'Error { message = "a ledger line must be a record" }
else if !(std.record.has_field "event" line) || !(std.is_string line.event) then
'Error { message = "event must be a string" }
else if !(std.record.has_field line.event needs) then
'Error { message = "unknown event %{line.event}" }
else
let missing = std.array.filter (fun f => !(std.record.has_field f line)) needs."%{line.event}" in
let bad = std.array.filter (fun f => std.record.has_field f line && !(is_whole line."%{f}")) counts in
if std.array.length missing > 0 then
'Error { message = "%{line.event} line is missing %{std.string.join ", " missing}" }
else if !(std.record.has_field "ts" line) || !(is_whole line.ts) then
'Error { message = "ts must be a whole number, zero or more" }
else if !(std.record.has_field "version" line) || !(std.is_string line.version) || line.version == "" then
'Error { message = "version must be a non-empty string" }
else if std.array.length bad > 0 then
'Error { message = "%{std.string.join ", " bad} must be whole numbers, zero or more" }
else
'Ok)
in
{ LedgerLine = line_contract }
Walking through it: is_whole accepts a number that is an integer and not negative. needs maps each of the four known events to the fields that event always carries; a confirmed line must carry restarts. counts lists the fields that must be whole numbers when present. line_contract is a validator, a function from a value to either 'Ok or 'Error { message = ... }, wrapped by std.contract.from_validator into a contract. It checks, in order: the line is a record, event is a string, the event is one of the keys of needs, no required field for that event is missing, ts is a whole number, version is a non-empty string, and the count fields are whole numbers. The first failure wins, which is why the messages come out one at a time.
The second file applies the contract to a whole ledger:
# ledger.ncl: apply the contract to every line of ledger.json (a JSON array of ledger lines).
let { LedgerLine, .. } = import "ledger-line.ncl" in
(import "ledger.json") | Array LedgerLine
It imports ledger.json, a JSON array, and applies Array LedgerLine to it, so every element must satisfy the line contract. nickel export ledger.ncl prints the first failing contract message. Nickel reads JSON, not JSONL, so the checker first turns each ledger into an array with Nushell: open --raw FILE | lines | each {|l| $l | from json } | to json -r.
The checker has three honest ledgers and three malformed ones. The real log:
contract flap: accepted
contract stable: accepted
contract chain: accepted
RESULT contract_accepts_honest PASS
contract norestarts: refused: confirmed line is missing restarts
RESULT contract_rejects_confirmed_without_restarts PASS
contract unknown: refused: unknown event healed
RESULT contract_rejects_unknown_event PASS
contract stringts: refused: ts must be a whole number, zero or more
RESULT contract_rejects_string_ts PASS
norestarts is stable with "restarts":1, removed. unknown renames confirmed to healed, an event the deployer never writes. stringts writes "ts":"31000" with quotes. The contract catches a missing field, an unknown event and a wrong type, and nothing else.
Four ledgers are forged but well formed:
early:stablewith theconfirmedtimestamp changed from 31000 to 12000.deleted:flapwith therolled_backline removed.badtarget:chainwith line 6's"to":"v1"changed to"to":"v2".revived:flapwhere therolled_backline was replaced by{"event":"confirmed","restarts":2,"ts":31000,"version":"v2"}.
The contract accepts all four:
contract early: accepted
contract deleted: accepted
contract badtarget: accepted
contract revived: accepted
RESULT contract_blind_to_forgeries PASS
That last PASS is a check that asserts a limitation: it passes when the contract is blind. Every individual line in these ledgers is shaped correctly. The contract is a useful first gate, because a ledger that fails it should not be trusted at all, but it cannot establish that a ledger is true.
Pitfall: "the ledger validated, so it is fine." Diagnostic:nickel exportexits 0 and prints no error for a ledger in which v2 is confirmed 19 seconds before its window could close. The output above (contract early: accepted) is exactly that symptom. Shape and meaning are different properties. A contract can only speak to shape.
Part 2: the query, derive then compare
audit.nu is the query. Its header sets the policy as constants:
const SOAK_MS = 30000
const MAX_RESTARTS = 2
const LEDGER_EVENTS = [restarting rolled_back confirmed restarted_post_soak]
Derive. derive reads the plan and returns, for each promotion, what the deployer must have produced:
def derive [plan: list] {
mut last_good = ($plan | where event == "serving" | get version | first | default "")
mut promos = []
for i in 0..<($plan | length) {
let p = ($plan | get $i)
if $p.event != "promoted" { continue }
mut exits = 0
mut outcome = {kind: "open"}
for e in ($plan | skip ($i + 1)) {
if $e.ts >= ($p.ts + $SOAK_MS) {
$outcome = {kind: "confirmed", ts: $e.ts}
break
}
if $e.event == "promoted" { break }
if $e.event == "exited" and $e.version == $p.version {
$exits += 1
if $exits > $MAX_RESTARTS {
$outcome = {kind: "rolled_back", ts: $e.ts, to: $last_good}
break
}
}
}
if $outcome.kind == "confirmed" { $last_good = $p.version }
$promos = ($promos | append {version: $p.version, start: $p.ts, restarts: ([$exits $MAX_RESTARTS] | math min), outcome: $outcome})
}
$promos
}
last_good starts as the version of the serving line, the baseline. For each promoted line the inner loop scans the later plan events in order. The first event whose timestamp is at or past the promotion plus 30 000 closes the window, and the outcome is a confirmation at that event's timestamp. A later promoted event ends the scan with the outcome still open. An exited event for this version increments a counter, and when the counter passes 2 the outcome is a rollback at that exit's timestamp, aimed at last_good. Only a confirmed release moves last_good, which is why chain's v3 rolls back to v1: v2 never became the last good release. The record also stores restarts, the exit count capped at the budget, which is how many restarting lines the ledger should hold. Run on flap, derive yields v2 with outcome rolled_back at 9 000 to v1 and two restarts. On stable it yields v2 confirmed at 31 000 with one restart.
The plan is read by a small helper that keeps only ts, event and version. Ticks have no version, so it fills in an empty string; the previous field is dropped, which is how the stale previous in chain is ignored.
The per-line pass. main reads the ledger as raw text, splits it on newlines, drops the empty piece after a final newline, and walks the lines with their numbers:
for it in ($raw | enumerate) {
let n = $it.index + 1
let parsed = if ($it.item | str trim) == "" { {ok: false} } else { try { {ok: true, v: ($it.item | from json)} } catch { {ok: false} } }
if not $parsed.ok {
$vs = ($vs | append $"VIOLATION line=($n) rule=schema version=- not valid JSON")
continue
}
let v = $parsed.v
let is_rec = (($v | describe) | str starts-with "record")
if not $is_rec or (($v.ts? | describe) != "int") or ($v.ts < 0) or (($v.event? | describe) != "string") or (($v.version? | describe) != "string") {
$vs = ($vs | append $"VIOLATION line=($n) rule=schema version=- ts, event or version missing or of the wrong type")
continue
}
if $v.event not-in $LEDGER_EVENTS {
$vs = ($vs | append $"VIOLATION line=($n) rule=unknown_event version=($v.version) event ($v.event)")
continue
}
if $v.ts < $prev_ts {
$vs = ($vs | append $"VIOLATION line=($n) rule=ts_order version=($v.version) ts ($v.ts) is earlier than ($prev_ts)")
}
$prev_ts = $v.ts
let to = if (($v.to? | describe) == "string") { $v.to } else { null }
$claims = ($claims | append {line: $n, ts: $v.ts, event: $v.event, version: $v.version, to: $to})
}
A line that does not parse, or parses but has the wrong types, is recorded as a schema violation with its number, and the loop moves on with continue. It does not stop the audit. An unknown event is recorded the same way. A line that passes is compared with prev_ts, the timestamp of the previous valid line: a smaller value is a ts_order violation, and the line still becomes a claim for the next stage. Lines that failed earlier checks never update prev_ts, so a cut line does not hide a backwards timestamp that follows it.
Direction 1: every claim needs support.
for c in $claims {
let hits = ($promos | where version == $c.version)
let promo = if ($hits | is-empty) { null } else { $hits | last }
if $c.event == "confirmed" {
if $promo == null {
$vs = ($vs | append $"VIOLATION line=($c.line) rule=confirmed_unsupported version=($c.version) never promoted in the plan")
} else if $c.ts < ($promo.start + $SOAK_MS) {
$vs = ($vs | append $"VIOLATION line=($c.line) rule=confirmed_before_window version=($c.version) confirmed at ($c.ts) but the window closes at ($promo.start + $SOAK_MS)")
} else if not ($promo.outcome.kind == "confirmed" and $promo.outcome.ts == $c.ts) {
$vs = ($vs | append $"VIOLATION line=($c.line) rule=confirmed_unsupported version=($c.version) the plan does not confirm this release at ($c.ts)")
}
} else if $c.event == "rolled_back" {
if $promo != null and $promo.outcome.kind == "rolled_back" and $promo.outcome.ts == $c.ts {
if $c.to != $promo.outcome.to {
$vs = ($vs | append $"VIOLATION line=($c.line) rule=rollback_target version=($c.version) to ($c.to | default '-') but the last confirmed release is ($promo.outcome.to)")
}
} else {
$vs = ($vs | append $"VIOLATION line=($c.line) rule=rollback_unsupported version=($c.version) the plan has no rollback at ($c.ts)")
}
}
}
For a confirmed claim there are three failures, tried in order. The release was never promoted: confirmed_unsupported. The claim is earlier than promotion plus 30 000: confirmed_before_window. The window had closed, but the plan does not confirm this release at this timestamp (for instance, the plan rolled it back): confirmed_unsupported. For a rolled_back claim, the plan must contain a rollback of that release at that exact timestamp, otherwise rollback_unsupported; and its to must equal the derived target, otherwise rollback_target. The loop works over claims, a list built before this loop starts, rather than inside a closure that touches a changing variable (see the pitfall in the Lab).
Direction 2: every outcome needs a claim.
for p in $promos {
let said = ($claims | where version == $p.version | get event)
if $p.outcome.kind == "confirmed" and "confirmed" not-in $said {
$vs = ($vs | append $"VIOLATION line=none rule=missing_confirmed version=($p.version) the plan confirms it at ($p.outcome.ts)")
}
if $p.outcome.kind == "rolled_back" and "rolled_back" not-in $said {
$vs = ($vs | append $"VIOLATION line=none rule=missing_rollback version=($p.version) the plan rolls it back at ($p.outcome.ts)")
}
let n = ($claims | where event == "restarting" and version == $p.version | length)
if $n != $p.restarts {
$vs = ($vs | append $"VIOLATION line=none rule=restart_count version=($p.version) ledger has ($n) restarting lines, plan implies ($p.restarts)")
}
}
This iterates over derived promotions, not ledger lines, so it can notice what is absent. A derived confirmation with no confirmed claim for that version is missing_confirmed; a derived rollback with no rolled_back claim is missing_rollback; both print line=none. Separately, the number of restarting lines for a release must equal the derived restart count, otherwise restart_count.
Summary and exit codes.
for v in $vs { print $v }
print $"ledger: lines=($lines) violations=($vs | length)"
exit (if ($vs | is-empty) { 0 } else { 1 })
Every violation is printed, then a final line ledger: lines=<n> violations=<k>. The script exits 0 when clean and 1 when any violation was found. Exit 2 is for a file that cannot be read; main checks both paths exist at the top and exits 2 if either is missing, or if the plan is not valid.
The honest ledgers, then the forgeries. All three honest ledgers print a clean summary:
flap: ledger: lines=3 violations=0 exit=0
stable: ledger: lines=2 violations=0 exit=0
chain: ledger: lines=6 violations=0 exit=0
The early confirmation: derive says v2's window opens at 1 000 and closes at 31 000. Line 2 claims 12 000, so direction 1 fires. Direction 2 finds a confirmed line for v2 and stays quiet.
VIOLATION line=2 rule=confirmed_before_window version=v2 confirmed at 12000 but the window closes at 31000
ledger: lines=2 violations=1
exit=1
The deleted rollback: the two remaining restarting lines are fine one by one and match the derived restart count of 2. Direction 1 has nothing to complain about, because no remaining claim is false. Direction 2 sees a derived rollback with no claim:
VIOLATION line=none rule=missing_rollback version=v2 the plan rolls it back at 9000
ledger: lines=2 violations=1
exit=1
The wrong target: line 6 is v3's rollback, which claims v2. The derived target is v1, because v2 was rolled back and never confirmed.
VIOLATION line=6 rule=rollback_target version=v3 to v2 but the last confirmed release is v1
ledger: lines=6 violations=1
exit=1
The revived rollback needs both directions. Line 3 claims v2 was confirmed at 31 000. That is not before the window closes (31 000 equals 1 000 + 30 000), but the plan rolled v2 back at 9 000, so the claim is unsupported. And the rollback itself is now missing:
VIOLATION line=3 rule=confirmed_unsupported version=v2 the plan does not confirm this release at 31000
VIOLATION line=none rule=missing_rollback version=v2 the plan rolls it back at 9000
ledger: lines=3 violations=2
exit=1
A rollback turned into a confirmation hides a failed release behind a line that looks like a success. Each direction alone reports half of it.
**Pitfall: the summary says violations=0 on a ledger you know is forged.** Diagnostic: this is what the starter tree prints for the early and deleted ledgers (and for the badtarget and revived ledgers too).
>
ledger: lines=2 violations=0 RESULT nu_early_caught FAIL ledger: lines=2 violations=0 RESULT nu_deleted_omission_caught FAIL
>
The starter only counts lines and names those that are not valid JSON records. The summary line was correct about what the starter checked, and silent about everything it did not check. A clean summary is only as strong as the rule list behind it.
Part 3: counting honestly
The last ledger is damaged, not forged. It is stable with line 2 cut off mid-write and a third line whose timestamp goes backwards:
{"attempt":1,"event":"restarting","max":2,"ts":3000,"version":"v2"}
{"event":"confirmed","restarts":1,"ts":31
{"attempt":2,"event":"restarting","max":2,"ts":2000,"version":"v2"}
The auditor must not stop at the broken line, and it must not shrug it off either. Real output:
VIOLATION line=2 rule=schema version=- not valid JSON
VIOLATION line=3 rule=ts_order version=v2 ts 2000 is earlier than 3000
VIOLATION line=none rule=missing_confirmed version=v2 the plan confirms it at 31000
VIOLATION line=none rule=restart_count version=v2 ledger has 2 restarting lines, plan implies 1
ledger: lines=3 violations=4
exit=1
Why four:
- Line 2 does not parse:
schema, with its line number. It is skipped, not fatal. - Line 3 has
ts2000, earlier than the 3000 of the previous valid line (line 1, since line 2 was skipped):ts_order. - The plan confirms v2 at 31 000, and the one line that should have said so is the cut one, so no
confirmedclaim survived:missing_confirmed. The damaged line is counted, and the claim it destroyed is counted too. - The ledger now holds two
restartinglines for v2, but the plan has a single exit:restart_count.
The ledger has three lines and four violations. The counts are not one per line, because a violation can concern a missing claim.
The checker cannot run the contract on this ledger. Its conversion of the JSONL file into a JSON array fails on the damaged line, so the contract stage never starts. The query handles the same file because it parses line by line and treats a parse failure as an event to record.
Pitfall: a damaged line is reported, but the other three problems are not. Diagnostic: this is the starter on the damaged ledger.
>
ledger: lines=3 violations=1 RESULT nu_damaged_line_counts FAIL
>
One is the schema violation; the reference reports four. A starter that names the broken line has handled the damage but not the consequences of it: the timestamp regression, the lost confirmation and the extra restart. The failure is not in the parsing; it is that the omission and ordering rules are missing.
Lab
The lab is a tree with lab/starter (the contract and the query with TODO comments), lab/reference (the finished versions) and lab/check.sh. To run it, copy a tree so that check.sh, ledger-line.ncl, ledger.ncl, audit.nu and the fixtures/ directory sit together, then run sh check.sh there. Every output in this lesson was observed running the lab with Nickel 1.18.0, Nushell 0.116.1 and jq 1.7.1. You need Nickel and Nushell at those versions; there is no network access and nothing is installed or started. fixtures/ holds the three plans and honest ledgers, the revived ledger, and fixtures/rust/*.out, which holds what a Rust auditor printed for the same eight ledgers. Those Rust outputs are the reference numbers: the query is right when it prints the same rule, line number and version for every violation, and the same summary line.
The starter's ledger-line.ncl checks only that event and version are strings and ts is a number, and its audit.nu only counts lines. Your work:
- In
ledger-line.ncl, add the per-event required fields, the unknown-event error, and the whole-number checks. - In
audit.nu, implementderive, the schema/unknown/ts_order pass, both comparison directions, and the summary and exit codes.
The checker builds the forged and damaged ledgers from the honest fixtures with sed and grep, so you never edit data by hand. It writes each ledger to its own directory before running the contract, because ledger.ncl imports ledger.json from the directory it sits in. It prints one result line per check, RESULT followed by the check name and PASS or FAIL. The checks are:
contract_accepts_honestcontract_rejects_confirmed_without_restartscontract_rejects_unknown_eventcontract_rejects_string_tscontract_blind_to_forgeriesnu_honest_cleannu_early_caughtnu_deleted_omission_caughtnu_wrong_target_caughtnu_damaged_line_countsnu_revived_both_directionsnu_matches_rustmissing_file_exit_2
The starter passes 5 of 13 and fails 8, so it fails the gate. An excerpt of its output:
contract norestarts: accepted
RESULT contract_rejects_confirmed_without_restarts FAIL
contract unknown: accepted
RESULT contract_rejects_unknown_event FAIL
same as Rust: 3 of 8 ledgers
RESULT nu_matches_rust FAIL
The reference passes all 13. The comparison with the Rust output works on the stable part of each line: it cuts every VIOLATION line down to VIOLATION line=... rule=... version=... and leaves the summary lines whole. The free text after the version is allowed to differ, and does for the schema violation: the Rust auditor printed EOF while parsing an object at line 1 column 41, a message from its JSON parser, where the query prints not valid JSON. The reference prints same as Rust: 8 of 8 ledgers.
Two pitfalls turned up while this lab was written.
**Pitfall:error: infinite recursion ... recursive referencefrom Nickel.** Diagnostic: it appeared when the file ended with{ LedgerLine = LedgerLine }, where theletbinding and the record field had the same name. The fix is a different binding name, which is why the reference usesline_contractand exports{ LedgerLine = line_contract }.
**Pitfall:Capture of mutable variablefrom Nushell.** Diagnostic: it appears when a closure, such as the body ofeach, refers to amutvariable. Nushell does not let a closure capture one.audit.nuavoids it by doing its lookups withwhereandgetover lists that are not changing at that point, such as$claimsand$promosin the comparison loops, and by usingforloops where it has to update a variable.
Other paths
jq. jq is a command-line JSON processor; here it can group a ledger by release. jq-audit.sh runs two queries: one per line (a confirmed line without restarts), and one per release, using group_by(.version) to list each release's events in order:
jq -c 'select(.event == "confirmed" and (has("restarts") | not)) | {ts, version}' "$1"
jq -s -r 'group_by(.version) | .[] | "\(.[0].version): \(map(.event) | join(", "))"' "$1"
The first prints each matching line. In the second, -s reads all lines into one array and group_by collects the lines of each version; the string template prints the version and its events joined by commas. With jq 1.7.1, the honest flap ledger and the one with the rollback deleted print:
$ sh jq-audit.sh flap.ledger
# per line: confirmed lines without restarts
# per release: events in order
v2: restarting, restarting, rolled_back
exit=0
$ sh jq-audit.sh deleted.ledger
# per line: confirmed lines without restarts
# per release: events in order
v2: restarting, restarting
exit=0
The two outputs differ by one word, and a person who knows what a failed release looks like can read the second one as suspicious. jq itself cannot: it never reads the plan, so it has no idea whether rolled_back belongs there. A per-line filter sees only lines that exist, and an omission is the absence of one. Grouping makes the gap visible to a human, not a program, and exit 0 in both cases is the honest answer to the question jq was asked.
Hashes and signatures. The tool this lesson does not build is the stronger one. AWS CloudTrail log file integrity validation uses SHA-256 hashes and SHA-256 with RSA digital signatures, with hourly digest files that each contain the signature of the previous digest, to determine "whether a log file was modified, deleted, or unchanged after CloudTrail delivered it". The page says this makes it "computationally infeasible to modify, delete or forge CloudTrail log files without detection". That detects tampering with the files themselves. The audit here detects a ledger that is inconsistent with its plan, which is a different and weaker guarantee.
Citations
- Kleppmann, Designing Data-Intensive Applications, O'Reilly, 1st edition (2017), chapter 11 "Stream Processing", section "Event sourcing", subsections "Deriving current state from the event log" and "Advantages of immutable events".
- AWS CloudTrail User Guide, "Validating CloudTrail log file integrity" (https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-log-file-validation-intro.html), the passage on determining whether a log file was modified, deleted or unchanged, and the SHA-256 / SHA-256 with RSA digest files.
- Amazon CloudWatch Logs User Guide, "Filter pattern syntax for metric filters, subscription filters, filter log events, and Live Tail" (https://docs.aws.amazon.com/AmazonCloudWatch/latest/logs/FilterAndPatternSyntax.html), the introductory paragraph: "Filter patterns only return the log events that contain the terms you define."
- AWS Certified DevOps Engineer - Professional (DOP-C02) Exam Guide, version 1.6, Domain 4, Task Statement 4.2, "Audit, monitor, and analyze logs and metrics to detect issues" (https://d1.awsstatic.com/training-and-certification/docs-devops-pro/AWS-Certified-DevOps-Engineer-Professional_Exam-Guide.pdf).