Promises¶
Every user-facing claim this project makes, and the check that keeps it true. A claim with no verifying job is either deleted or labelled Unverified — it is never left to look proven. A claim checked only by a human running a command by hand, not by CI, is labelled Verified (manual) naming that step. A claim where part of it is genuinely tested and part is not is labelled Verified (partial), with the "Verified by" column stating exactly which part.
Where this stands¶
62 user-facing claims are tracked below: 26 Verified, 9 Verified (manual), 17 Verified (partial), 10 Unverified. Read that last number as the point of the exercise, not an apology — most projects of this size do not know which of their claims are unproven, because nothing forces the question. Everything here was checked against the code, the CI logs, or the shipped artifact before it was written down.
What that verification actually looks like, rather than a green badge:
- Mutation, not reading. Where a row says "verified by mutation", the
behaviour was broken on purpose and the test watched to fail — UTF-16
decoding, the 64-peer cap, the peer-stamp placement, the
/healthexpiry boundaries, the syslog SD-PARAMs. Several tests that looked like proof turned out to pass with the feature deleted; those were rewritten, and the history is kept in the rows rather than tidied away. - Negative controls. The
static-binaryjob asserts against a deliberately CGO-enabled build that must fail the check, so a broken assertion cannot masquerade as a clean binary. - The real platform. A
windows-latestjob executes the Windows-tagged code — message-resource rendering per event ID and a full SCM install/start/stop/uninstall lifecycle — on every push..evtxfiles generated on Linux are read back byGet-WinEventon Windows, and independently by python-evtx. - The shipped artifact, not a local build. Release archives are checked
against their published checksums, then asserted for static linking, CGO
settings,
LICENSE, the version stamp reaching the running binary, and a multi-arch container index. - Live fire. Alert rules were verified by stopping a real CEE publisher and then the exporter itself, and watching the right alert — and only the right alert — fire and resolve.
New claims arrive with their job. That is the rule this file exists to
enforce. Claims below are primarily drawn from README.md, docs/index.md,
and docs/PRD.md (including its Success Metrics section) as they stood on
2026-08-08 — but the "Where stated" column names the actual source file for
each claim, which for several rows turned out to be CLAUDE.md,
docs/operator-guide.md, docs/requirements.md, or CHANGELOG.md instead,
once each attribution was checked against the real file content rather than
assumed. Every "Verified by" test name below was opened and read before
being cited here — see docs/requirements.md for the fuller per-requirement
traceability this table draws on.
CEPA protocol¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
RegisterRequest handshake returns a <RegisterResponse> with an identity CEE accepts |
README.md Quick Start ([cepa]), CLAUDE.md, docs/requirements.md CEPA-01 |
pkg/server/server_test.go::TestServeHTTP_RegisterRequest_ReturnsRegisterResponse (attributes + non-empty filter) and TestServeHTTP_RegisterResponse_IsUTF16WhenAsked (encoding), both mutation-tested; verified end to end against PowerStore diabps01 on 2026-08-22, where it took CEE from 0x16 with 151 discarded events to 0x0 with events flowing and a .evtx read back by Get-WinEvent. The two named tests cannot reach the second half of this claim: they assert the document's attributes, filter and encoding, which is all a unit test can see. Whether CEE accepts the identity is decided inside CEE against its compiled-in CGuidStore allowlist, so the only evidence for it is the dated PowerStore run above |
Verified (manual) — and this row previously claimed the exact opposite. It asserted "HTTP 200 with a strictly empty body", sourced from a code comment that cited "Dell CEPA documentation" nobody had checked. The test that "verified" it asserted zero bytes, so the evidence was real and the claim it verified was wrong — a reminder that a green test only proves the code does what the test says, not that the test says the right thing. It is the reason two bring-ups ended silent. |
| The handler ACKs the heartbeat within the 3-second CEPA budget | README.md, docs/PRD.md NFR |
The handler ACKs without waiting for the writer; verified by TestServeHTTP_ACKsBeforeQueueWork, which proves ServeHTTP returns a written 200 while the queued write is provably still in progress. TestServeHTTP_LargeBatchACKsWellUnder3s additionally asserts a 2000-event VCAPS batch ACKs in under 2s (the test's own doc comment measures ~233ms under -race locally; re-run for this review at 0.19s), well inside the 3s limit (CI ci job, make test). Neither test times a <HeartBeatRequest />: one proves ACK-before-queue-work ordering, the other times a 2000-event VCAPS batch. The heartbeat's own wall-clock latency against the 3s limit is not measured — see the dedicated Unverified row below, which this row must not be read as closing (CI ci job, make test) |
Verified (partial) — the ordering guarantee and the batch case are measured; heartbeat latency itself is not |
| Malformed/non-RegisterRequest bodies still get a 200, not a 4xx/5xx (CEPA must never be told the endpoint is unreachable) | Not stated in any user-facing doc — this is an internal reliability guarantee documented only as a code comment (pkg/server/server.go:80, "Still ACK so CEPA doesn't mark us unreachable"). Included here because it is a core CEPA reliability property, not because README/index.md assert it |
pkg/server/server_test.go::TestServeHTTP_ParseErrorStillACKs |
Verified |
| CEE sends the CEPA handshake as UTF-16LE without a BOM, and the parser decodes UTF-16 for both handshake and event payloads | docs/operator-guide.md; issue #32 |
pkg/parser/utf16_test.go::TestIsRegisterRequest_RealCEEPayloads asserts against the exact 38-byte body captured from CEE 9.2.0.0 with tcpdump (written byte-by-byte, not transcoded with the same encoder the production code uses); TestParse_UTF16Payloads covers single and batch payloads in UTF-16LE/BE with and without a BOM; TestDecodeUTF16_LeavesUTF8Alone guards the detection heuristic against mangling UTF-8. Verified by mutation — removing the decode fails 9 subtests while the UTF-8 cases keep passing. Additionally verified end-to-end against three live CEE 9.2.0.0 publishers on 2026-08-11: cee_cepa_registrations_total went from a constant 0 to incrementing once per heartbeat, with zero cepa_parse_error lines. TestParse_TruncatedUTF16Rejected covers an odd trailing byte, verified by mutation — neutralising the length check makes Parse accept a truncated payload as 1 event. What is NOT verified: that CEE encodes event payloads as UTF-16 at all. Only CEE handshakes were observable: no CEEEvent has ever been seen on the wire, because no Dell CEE instance has ever forwarded one (CEE answers both array families status="0x16" and they publish nothing through it). OneFS event payloads have since been captured, but those come straight from the cluster in UTF-8 and say nothing about how CEE would encode a CEEEvent. The decoder handles both encodings, so it is correct either way, but the claim that CEE sends events as UTF-16 rests on the handshake's encoding and the Accept-Charset: utf-16 header, not on a capture (CI ci job, make test) |
Verified (partial) — the handshake wire format is measured and the decoder is tested for both encodings; the event wire format is inferred, not observed |
| Before v5.3, every CEE handshake fell through to the parse-error branch and every UTF-16 event payload was ACKed with HTTP 200 and dropped | issue #32 | Measured, not inferred: three publishers logged cepa_parse_error every 10s, and a UTF-16LE <CEEEvent> PUT to the running exporter returned 200 while cee_events_received_total stayed at 0. The handshake appeared to work only because the parse-error branch returns 200, which kept the transport alive; the empty body it returned is precisely what CEPA-01 forbids, so CEE registered no partner |
Verified — historical defect, fixed |
VCAPS bulk batch payloads (<EventBatch>) are parsed |
docs/PRD.md CEPA-04 (by ID only — the detail is in CLAUDE.md: "Handles both single-event and VCAPS batch (<EventBatch>) payloads") |
pkg/parser/parser_test.go::TestParse ("vcaps_batch_two_events" case) |
Verified |
| Heartbeat latency is bounded, not just "ACK ordering is correct" (CEPA-02) | docs/PRD.md NFR |
Nothing measures wall-clock response latency against the 3s limit directly — the ordering guarantee above is tested, not the latency number itself | Unverified — do not treat CEPA-02 as closed by the ACK-ordering tests above; see docs/requirements.md |
CEPA event types map to the correct Windows EventID and access mask (CEPP_CREATE_FILE/CEPP_CREATE_DIRECTORY/CEPP_FILE_READ/CEPP_FILE_WRITE/CEPP_CLOSE_MODIFIED → 4663; CEPP_DELETE_FILE/CEPP_DELETE_DIRECTORY → 4660; CEPP_SETACL_FILE/CEPP_SETACL_DIRECTORY → 4670) — the semantic core of the product |
README.md (CEPA protocol compliance bullet, implicitly); docs/requirements.md MAP-01..MAP-06 (six requirement IDs, one per event-type family) |
pkg/mapper/mapper_test.go::TestMapEventID table-drives nine concrete CEPP_* types plus an unknown-type fallback (not "six" — that was the requirement-ID count, not the test's case count) asserting both EventID and AccessMask for each; TestMapFieldPropagation covers field propagation, e.g. I/O stats on CEPP_CLOSE_MODIFIED |
Verified |
| PowerScale (OneFS) clusters can publish protocol audit events to cee-exporter | Not claimed anywhere user-facing — recorded here because the question was asked and the answer must not drift into an assumed yes. README.md, docs/index.md and docs/PRD.md name PowerStore only |
Measured end to end against a live 4-node OneFS 9.13.0.0 cluster on 2026-08-14. All four nodes complete the handshake (cepa_check_file_request, 233 bytes in / 311 out) and file events arrive and are written: cee_events_received_total and cee_events_written_total both moved off zero for the first time, carrying the full UNC path, the acting NFS client address and microsecond timestamps. Every open question this row previously listed is now answered: OneFS does not send a RegisterRequest — it opens with CheckFileRequest and treats an empty reply as fatal; the payload is plain UTF-8, not the UTF-16LE that CEE sends; and it carries events in the same element as the heartbeat, distinguished only by Args/@action (9 = heartbeat, 11 = event). Event types are a numeric bitmask in NFSEventArgs/@eventType, not the CEPP_* family — all six values resolved by an isolation run of one operation per 10-second window (8 create, 32 delete, 128 close-modified, 256 close, 512 rename, 2048 set_security). Covered in CI by pkg/parser/onefs_event_test.go and pkg/server/onefs_event_test.go, both built on payloads captured off the wire rather than written to match the parser. What is NOT verified: that this is a supported topology — the cluster publishes CEPA directly to this daemon with no Dell CEE in the path, which Dell does not support, and CEE 9.2.0.0/9.3.0.0 both refuse the same cluster's heartbeat (status="0x16", VC_ERROR_CEPP_NOT_FOUND) |
Verified (manual) — measured against a live cluster on 2026-08-14; the parsing and mapping are covered by CI, the live end-to-end path is not |
OneFS opens with <CheckFileRequest>, not <RegisterRequest>, and requires a <CheckFileResponse> back — the opposite of PowerStore, where any response body is fatal |
CHANGELOG.md (Unreleased); pkg/server/server.go checkFileResponse |
Measured on the wire (tcpdump, OneFS 9.13.0.0 → CEE 9.2.0.0, 2026-08-12) and then end to end: with the handshake answered, all four nodes heartbeat cleanly and the cluster's Protocol Audit Cee Time — stuck for ten hours — advanced to match Protocol Audit Log Time. This is a manual measurement against hardware CI cannot reach; what CI guards is the code path, not the cluster's acceptance: pkg/parser/onefs_test.go::TestIsCheckFileRequest_RealOneFSPayload asserts the byte-exact 229-byte capture is recognised and is not taken for a RegisterRequest, and pkg/server/onefs_test.go::TestServeHTTP_CheckFileRequest_AnswersWithResponse asserts the reply is well-formed CheckFileResponse XML with status="0x0" — the mirror of TestServeHTTP_RegisterRequest_ReturnsRegisterResponse, so the two dialects cannot drift into agreement. Not verified: that the same HeartBeatResponse body is the correct answer to an event request (action="11"). It is returned verbatim because the cluster must be answered, and no capture of CEE replying to an event request exists (CI ci job, make test; the cluster result is manual) |
Verified (manual) — the handshake and its acceptance are measured; the reply to an event request is not |
The PowerStore CEPA dialect (POST, UTF-16LE) is accepted and answered correctly |
CHANGELOG.md Unreleased |
Captured off the wire from a live PowerStore NAS server on 2026-08-14: it publishes POST /vee with User-Agent: EMC Data Mover and a UTF-16LE body, where Dell CEE and OneFS both use PUT and OneFS uses UTF-8. Before this the handler accepted only PUT and returned 405 on every heartbeat. pkg/server/powerstore_test.go covers all three parts against the captured heartbeat — POST is accepted, the CheckFileResponse comes back UTF-16LE-encoded with status="0x0", and the OneFS path still answers in UTF-8 so mirroring the request encoding did not regress it (CI ci job, make test). What is NOT verified, and is known false for at least one PowerStoreOS version: that a PowerStore array can therefore publish here directly. The Events Publisher makes Microsoft RPC mandatory — it cannot be unticked on an existing or a newly created publisher — and this daemon serves HTTP only. Measured: with the pool listing a Linux consumer first and a Windows CEE host second, the array skipped the first entry without ever opening a TCP connection to it. A Linux host cannot be a direct CEPA target for such an array |
Verified — the dialect handling is covered by CI; the direct-publishing topology it was built for is separately disproven above |
The CEPA client sends events over plain HTTP only — always register http://, never https://, in PowerStore |
docs/index.md, docs/PRD.md "Protocol Constraint" |
Nothing runs an actual PowerStore CEPA client against this daemon; this is a protocol fact about Dell's implementation, external to this codebase, not something a test in this repo can exercise. Correct by design: pkg/server never requires TLS and the operator guide's TLS section repeats the same warning |
Unverified — external protocol behaviour, not verifiable from this repo; documented consistently is the best available assurance |
Output backends¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
| GELF 1.1 JSON output over UDP or TCP → Graylog | README.md, docs/index.md |
pkg/evtx/writer_gelf_test.go verifies payload construction and GELF 1.1 field validity only — no test dials a real UDP or TCP socket, and writer_helpers_test.go's sendWithRetry tests use a fake closure, not GELFWriter.connect()/send() |
Verified (partial) — message format is verified; on-the-wire UDP/TCP delivery is not |
| RFC 5424 syslog output over UDP or TCP | README.md, docs/index.md |
pkg/evtx/writer_syslog_test.go::TestBuildSyslog5424 (RFC 5424 message format). Before v5.0.1 it asserted bare substrings and named only four of the seven audit@32473 SD-PARAMs, so deleting Domain, AccessMask or ClientAddr left it green under a subtest called "all required fields present"; it now asserts each rendered Key="Value" pair plus the header fields, and deleting any one AddDatum fails it. TestBuildSyslog5424ProcID covers the PROCID nil-value branch. TestSyslogTCPFraming verifies RFC 6587 octet-counting framing against a real net.Conn via net.Pipe, exercising the writer's actual send() method |
Verified (partial) — TCP framing is exercised against a real connection; UDP is checked for message format only, not dialed |
| Beats/Lumberjack v2 output to Logstash/Graylog, with optional TLS | README.md, docs/index.md |
pkg/evtx/writer_beats_test.go::TestBuildBeatsEvent (event field mapping) and TestBeatsWriterDialerInjection. Before v5.0.1 the latter proved nothing about TLS: both its cases dialled an unreachable port, where the TCP connect is refused before any handshake, so they returned the identical error and deleting the entire TLS branch left the test green. It now runs against a real listener — plain TCP must connect, TLS: true must fail at the TLS layer rather than silently succeed in the clear, and an already-cancelled context must abort the dial — each verified by mutation |
Verified (partial) — event mapping is verified, and the TLS branch is now proven to be taken rather than merely configured; still, no test completes a TLS handshake against a real Beats server or performs a successful Lumberjack send |
Native .evtx files written on non-Windows platforms |
README.md, docs/index.md, docs/PRD.md OUT-05 |
pkg/evtx/writer_evtx_notwindows_test.go (TestBinaryEvtxWriter_WriteClose, TestBinaryEvtxWriter_ChunkLayout, and others) |
Verified |
Generated .evtx files open correctly in Windows Event Viewer and parse with forensics tools |
docs/PRD.md OUT-06, Success Metrics |
The evtx-readback job in .github/workflows/ci.yml downloads a .evtx generated by the Linux evtx-oracle job and reads it on windows-latest with Get-WinEvent -Path, asserting 3 records, the event ID set {4660, 4663, 4670}, ToXml() per record, and ObjectName in the rendered XML. evtx-oracle additionally checks the file with python-evtx (tools/evtx-debug/verify_evtx.py) — an independent parser, which is the "forensics tools" half, though it is measurably lenient: it parses the pre-v0.7.0 files Get-WinEvent rejects as The event log file is corrupted. What CI does not cover: description rendering, which needs a host with the event source registered. An initial reading on 2026-08-10 (winvm, Windows Server 2025 Datacenter) found all three records enumerating with LogName=[] and Message: <null> even with the source confirmed registered — traced to this project's own -emit-test-events fixture leaving ProviderName empty, something no event pkg/mapper ever produces, not a go-evtx or saved-log defect (isolation in CHANGELOG.md's [5.1.0] entry). With ProviderName set, re-measured 2026-08-10: Get-WinEvent -Path on the same saved log resolves all three descriptions correctly. A second, separate defect was found and fixed the same day: windowsEventToFields never copied WindowsEvent.Channel into the map handed to go-evtx, even though pkg/mapper has set Channel: "Security" on every mapped event since v2 — every record this exporter has ever written therefore rendered <Channel></Channel>, resolving LogName to the empty string. Passing Channel through, with evtx.DefaultChannel = "Security" covering -emit-test-events, fixed it: the same three records now measure LogName=[Security], where they measured LogName=[] before (see CHANGELOG.md's [5.1.0] entry). pkg/evtx/writer_evtx_notwindows_test.go::TestWindowsEventToFields_Channel covers both the pass-through and the default, and evtx-oracle's verify_evtx.py now asserts System/Channel == "Security" in the generated XML on every push. The Event Viewer GUI open/placement question (does it open, where do events land) was run on 2026-08-10, over an RDP session to winvm — the earlier SSH-only connection could not reach an interactive desktop — against the released v5.1.0 binary downloaded from GitHub, not a local build: the saved log opens via Action → Open Saved Log…, lists under Saved Logs → released-v5.1.0 with "Number of events: 3", all three IDs {4660, 4663, 4670} present, Log Name: Security in the General pane, no convert prompt — see docs/windows-verification.md section 5. A pre-run prediction was falsified there: headless Get-WinEvent measurement found .LevelDisplayName/.TaskDisplayName/.OpcodeDisplayName/.KeywordsDisplayNames all empty and predicted the Level/Task Category/Keywords columns would render blank; they did not — Event Viewer supplies its own defaults (Information/None/Info/None) for those zero values, because the empty PowerShell properties reflect the absence of provider metadata to resolve a display name against, not the absence of a value the GUI displays |
Verified — Get-WinEvent read-back (file opens, 3 records, the event ID set, ToXml(), ObjectName) and System/Channel == "Security" are CI-gated on every push; description rendering, LogName resolving to Security, and the Event Viewer GUI open/placement question are each covered by the dated 2026-08-10 manual protocol in docs/windows-verification.md — not CI-gated, since the first two need a host with the event source registered and the GUI question additionally needs an interactive desktop session |
Win32 EventLog writer registers a "PowerStore-CEPA" source against the exporter's own executable and writes via ReportEvent |
README.md/docs/index.md state only the ReportEvent writing; the "PowerStore-CEPA" source name is documented only in CHANGELOG.md's [1.0.0] entry and the code itself (win32SourceName); "against the exporter's own executable" is pkg/evtx/writer_windows.go's package comment, rewritten for v5.0 |
pkg/evtx/writer_windows_test.go::TestEnsureEventSource_PointsAtCurrentExecutable and ::TestEnsureEventSource_RepointsStaleSource — the first tests in this repo's history to execute writer_windows.go at all — run in the windows CI job (.github/workflows/ci.yml), confirmed green in run 31315601780 (pkg/evtx ... ok, -race, real gcc, not skipped). That job also builds the actual release binary and drives it with -emit-test-events, exercising NewWin32EventLogWriter → ensureEventSource → eventlog.Install → ReportEvent end to end — its "Service install/uninstall round-trip" step additionally confirms real SCM registration (sc.exe query returned STATE: 1 STOPPED for a service that did not exist before install, not an error) |
Verified |
| Windows Event Log entries for 4660/4663/4670 render a real description in Event Viewer and other Event Log API readers, instead of the former placeholder text | pkg/evtx/writer_windows.go's package comment (rewritten for v5.0); docs/windows-verification.md |
The windows CI job's "Assert Event Viewer renders real text, not the placeholder" step asserts, for every one of 4660/4663/4670, that Get-WinEvent's .Message is non-null and contains the payload body, and that Get-EventLog's .Message does not contain the placeholder string — confirmed directly in run 31315601780's raw log (event 4660 renders correctly, event 4663 renders correctly, event 4670 renders correctly). That alone proves the resolution mechanism on a freshly-registered source; it does not by itself prove the upgrade case, since the CI runner has no prior state to upgrade from. docs/windows-verification.md (manual protocol, run 2026-08-09 on Windows Server 2025) closes that gap: it records the actual placeholder text (Get-EventLog: The description for Event ID '4663' in Source 'PowerStore-CEPA' cannot be found. ...; Get-WinEvent: $null) from a source genuinely registered the old, broken way, then the real post-upgrade text (An attempt was made to access an object. etc.) after running the v5.0 binary in place, with win32_source_repointing observed firing in the log — and separately confirms the registration-failure degradation path (win32_source_registration_failed logged, exporter still writes all three events and exits 0) under a genuine registry ACCESS_DENIED, though not via a truly separate non-administrator OS logon (that specific mechanic failed on the test host for reasons unrelated to this code — see that document's step 4). This claim does not extend to SIEM content-pack compatibility: whether a given SIEM's content pack, built to parse a genuine Windows Security-log event, treats this output as equivalent has not been demonstrated and is not asserted here |
Verified (manual) — the core rendering mechanism is proven by the automated windows CI job on every push; the full before/after against a genuinely pre-v5.0-registered source, and the degradation path, were confirmed by the dated manual run in docs/windows-verification.md, 2026-08-09 |
| Multi-target fan-out: one backend's failure doesn't block delivery to others | README.md/docs/index.md state only that fan-out exists, not the failure-isolation guarantee; that detail is docs/PRD.md NFR Reliability ("failed backend does not block others (MultiWriter)") |
pkg/evtx/writer_multi_test.go::TestMultiWriter_WriteEvent_AllCalledJoinedErrors |
Verified |
| SIGHUP triggers immediate EVTX rotation on non-Windows platforms | docs/operator-guide.md |
cmd/cee-exporter/sighup_notwindows.go has no dedicated test of the signal registration itself; pkg/evtx/writer_multi_test.go::TestMultiWriter_Rotate_ForwardsAndSkips verifies only MultiWriter's fan-out of Rotate() to rotator/non-rotator backends — nothing asserts a .evtx file actually rotates on disk (no TestBinaryEvtxWriter_Rotate) |
Verified (partial) — the Rotate() fan-out is tested; neither the SIGHUP wiring nor an actual file rotation is |
| TCP GELF/Beats connections reconnect automatically after a lost connection | docs/PRD.md NFR Reliability |
pkg/evtx/writer_helpers_test.go's sendWithRetry tests (TestSendWithRetry_FirstSendSucceeds, TestSendWithRetry_RetrySucceeds, TestSendWithRetry_ReconnectFails, TestSendWithRetry_RetryFails) exercise the shared retry helper with fake closures, not a real GELFWriter/BeatsWriter reconnecting over an actual dropped socket |
Verified (partial) — the retry/reconnect logic itself is tested; a real network reconnection is not |
TLS¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
tls_mode="self-signed" generates a runtime ECDSA certificate, no files or network needed |
README.md, docs/operator-guide.md |
cmd/cee-exporter/tls_test.go::TestBuildSelfSignedTLS asserts MinVersion, exactly one certificate, a validity window straddling now, and the expected DNS name(s) — it never inspects the key algorithm, so the "ECDSA" half of the claim is confirmed only by reading cmd/cee-exporter/tls.go:56 (ecdsa.GenerateKey(elliptic.P256(), …)), not by this test |
Verified (partial) — certificate shape and validity are tested; the key algorithm is correct by inspection only |
tls_mode="manual" (or legacy tls=true+cert_file/key_file) loads certificates from files, and the legacy form auto-migrates to tls_mode="manual" |
README.md states only that a "manual" mode exists; the legacy-field auto-migration detail is stated only in docs/operator-guide.md (added this release) and config.toml.example's "Backward compat" comment |
cmd/cee-exporter/tls_test.go::TestMigrateListenConfig covers config recognition and the legacy-field migration; no test drives an actual TLS handshake against the listener |
Verified (partial) |
tls_mode="acme" auto-obtains and renews a certificate from Let's Encrypt via TLS-ALPN-01 |
README.md, docs/operator-guide.md, docs/PRD.md TLS-03 |
cmd/cee-exporter/tls_test.go::TestMigrateListenConfig ("already_set_acme" case) covers config recognition only — nothing exercises a real ACME exchange, which needs a live ACME server |
Unverified for the actual certificate issuance/renewal; config wiring only |
TLS certificate expiry is surfaced via /health on every request, with a WARN log when fewer than 30 days remain |
README.md |
pkg/server/health_test.go::TestHealth_TLSCertExpiryPopulated confirms expiry is computed and exposed via /health. Since v5.0.1 three further tests pin the boundaries that one cannot see, because it uses a 400-day cert: TestHealth_TLSDaysRemainingSurvivesLastDay (a 12-hour cert, where the day count is 0 and the field must still be present), TestHealth_TLSDaysRemainingAbsentWhenNoCert (no cert, where it must be absent), and TestHealth_TLSDaysRemainingNegativeWhenExpired (already-expired certs, which int() truncation also reported as 0). Each mutation was run: int with omitempty fails the first, int without it fails the second, and int() in place of math.Floor fails the third. The tls_cert_expiry_soon WARN log itself (inside buildTLSInfo's days < 30 branch, run on every /health call — there is no separate startup check) still has no test |
Verified (partial) — the JSON contract including the nil-vs-0 distinction is verified; the WARN log is not |
Observability¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
/metrics exposes cee_events_received_total, cee_events_written_total, cee_events_dropped_total, cee_events_truncated_total, cee_writer_errors_total, cee_queue_depth, cee_last_fsync_unix_seconds, and cee_build_info |
The full, named list of eight metrics is stated only in docs/operator-guide.md's metrics table (completed this release). README.md/docs/index.md/docs/PRD.md Success Metrics only claim a Prometheus endpoint exists, not these specific names |
pkg/prometheus/handler_test.go::TestMetricsHandler_AllRequiredMetrics (the first seven) and TestBuildInfoMetric (cee_build_info with version/go_version labels), and confirms Go runtime metrics are absent from the private registry |
Verified |
/metrics is served on a dedicated, configurable port (default 9228) |
README.md, docs/index.md |
The default is set in cmd/cee-exporter/main.go (MetricsConfig.Addr = "0.0.0.0:9228"); no test exercises the actual port binding or a non-default value end-to-end |
Verified (partial) — metric content is tested; the port/binding itself is not |
GET /health returns JSON including queue_depth and the writer block (type/target), and returns HTTP 200 on the healthy path; degradation is signalled by the "status" field flipping to "degraded", not by the HTTP status field. The always-200 behaviour is a deliberate design decision, not a gap — see docs/operator-guide.md's Health endpoint section and the "Decided against" note in the promise-remediation design doc for why a 503 would make queue-overflow incidents worse, not better |
docs/operator-guide.md |
pkg/server/health_test.go::TestHealth_OKWithBaseline asserts w.Code == 200, status == "ok", queue_depth, and the writer.type/writer.target fields on the healthy path; TestHealth_MethodNotAllowed asserts a non-GET request gets 405 before any of this runs. TestHealth_DegradedWhenDropped asserts status flips to "degraded" but does not read w.Code. Neither test reads uptime_seconds or any events_*_total field — those are populated at health.go:82-86 but not asserted by any test. That health.go's ServeHTTP contains exactly one WriteHeader call on the GET path (:48, unconditional http.StatusOK) — the basis for "200 even when degraded" — is correct by direct reading of the function, not by a dedicated test |
Verified (partial) — queue_depth, the writer block, the status field's healthy/degraded values, and 405-on-non-GET are test-verified; uptime_seconds, the event counters, and "200 even when degraded" are correct by inspection only |
Structured JSON logging (slog) includes event/queue/latency fields on every batch |
README.md states only that structured JSON logging exists; the specific field list (event_type, file_path, client_ip, queue_depth, latency_ms) is stated only in docs/requirements.md OBS-02 |
Nothing captures/parses slog output to confirm field presence; correct by inspection of pkg/server/server.go |
Unverified |
| Dropped events (queue overflow) are logged at WARN with a running total | README.md's "async queue" bullet does not mention WARN logging or drop counts; the precise claim is CLAUDE.md ("Drops events with WARN log when full") and docs/operator-guide.md's metrics table (cee_events_dropped_total) |
pkg/queue/queue_test.go::TestDropOnFull verifies the counter increments; the WARN log line itself is not asserted by any test |
Verified (partial) |
| The async queue ACKs the HTTP request immediately and processes events in the background | README.md |
Covered by the CEPA-protocol ACK-ordering row above; queue mechanics (enqueue, drop-on-full, drain-on-stop) also covered by pkg/queue/queue_test.go |
Verified |
| Queue capacity defaults to 100,000 events | docs/PRD.md NFR Throughput |
cmd/cee-exporter/main.go:158 sets Capacity: 100000 as the default; no test asserts this specific default value (pkg/queue's tests all pass an explicit capacity) |
Verified (partial) — correct by inspection of the default; not asserted by a test |
| Binary size ~6 MB (stripped, CGO_ENABLED=0) | docs/index.md (previously — corrected in this release) |
Measured directly for this review: make build-linux produces a 12,038,306-byte (~11.5 MB) binary today, not ~6 MB. The stated figure was stale, not false when written — the binary has grown as dependencies (prometheus/client_golang, kardianos/service, golang.org/x/crypto/acme, github.com/fjacquet/go-evtx, etc.) were added across v2.0–v4.0 |
Unverified — was stale; superseded by the row below |
| Binary size ~11.5 MB (stripped, CGO_ENABLED=0, linux/amd64) | docs/index.md (current figure, corrected in this release) |
Re-measured on the shipped artifact, not a local build: the cee-exporter_5.3.1_linux_amd64.tar.gz binary downloaded from the v5.3.1 release is 12,099,746 bytes (2026-08-11), against 12,038,306 bytes for a local make build-linux on 2026-08-08. Both round to ~11.5 MB. No CI check pins or re-measures this number, so it can drift again exactly as the retired ~6 MB figure did |
Verified (manual) — measured on the released artifact; not asserted by any job |
/metrics exposes cee_cepa_last_request_unix_seconds and cee_cepa_registrations_total, both labelled remote, plus a label-free cee_cepa_peers_dropped_total |
docs/operator-guide.md metrics table |
pkg/prometheus/handler_test.go::TestMetricsHandler_CEPAPeerMetrics asserts the exact labelled text-format lines for two distinct publishers; TestMetricsHandler_CEPAPeerMetricsEmpty asserts no series and no remote="" artefact when no publisher has been seen (CI ci job, make test) |
Verified |
| Every CEPA request stamps its publisher — handshake, event batch, unparseable payload, and unreadable body alike — so a broken-but-alive publisher does not read as dead | docs/operator-guide.md "Publisher liveness" |
pkg/server/server_test.go::TestServeHTTP_StampsPeerOnEveryPath table-drives all four paths, including a body that fails mid-read. The body-read case is the one that pins the stamp's placement: the other three read their bodies successfully, so a stamp moved after readBody still passes them. Verified by mutation — moving the stamp after readBody fails only the body read error subtest, and moving it after the parse check fails the register-request and parse-error subtests (CI ci job, make test) |
Verified |
The remote label is bounded: the ephemeral port is stripped and distinct publishers are capped at 64, with overflow counted rather than silently dropped |
docs/operator-guide.md "Publisher liveness" (Cardinality) |
pkg/metrics/metrics_test.go::TestStore_PeerCap asserts the 65th publisher is rejected and PeersDropped increments — verified by mutation, removing the cap check fails it with 65 entries; pkg/server/server_test.go::TestServeHTTP_StampsPeerWithoutPort asserts three ports on one host collapse to one label value (CI ci job, make test) |
Verified |
| These metrics detect a CEE server that has gone silent, not a NAS Data Mover that stopped publishing into a healthy CEE server | docs/operator-guide.md "Publisher liveness" (What this does and does not detect) |
Nothing tests this — it is a scope limitation of what the transport can observe, stated so the metric is not read as covering more than it does. No test can verify an absence of capability | Unverified — documented limitation, not a tested behaviour |
The Grafana dashboard in dashboards/cee-exporter.json queries these metrics correctly |
README.md, docs/operator-guide.md |
Loaded into Grafana 11.5.1 via deploy/compose.yaml against a live Prometheus scraping a cee-exporter with three real CEE 9.2.0.0 publishers (2026-08-11). All 8 panel expressions were executed and every one returned data — 3 series for the per-publisher panels, 1 each for the aggregate panels, 0 empty. The dashboard provisions and its datasource resolves. No CI job does any of this. Panel rendering was captured on 2026-08-11 (one publisher) and re-captured on 2026-08-22 against a live estate with several publishers and a NAS server named in the events — docs/assets/grafana-dashboard.png now holds the 2026-08-22 frame, scrolled to the Diagnostics row (Publisher silence, Handshakes per publisher, Event throughput, Queue depth, Seconds since last fsync, Deliveries against connection cadence, Events by type and protocol, Events by NAS server). Every panel in that frame draws data with the units and legends it declares, and both template variables (publisher, server) resolve. This covers layout, legibility and datasource resolution; it says nothing about behaviour under load — the window contains one ~10-minute burst peaking at 2.78 evt/s, so the queue-depth and dropped panels are drawing zeroes, not a tested saturation path. The two stat panels and the More metrics row are above the top of the frame and so are not covered by this capture |
Verified (manual) — queries executed against live data on 2026-08-11, rendering captured then and re-captured 2026-08-22; nothing re-checks either on an ongoing basis, and no panel has been seen under sustained or VCAPS-batch load |
The alert rules in deploy/prometheus/alerts.yml fire and resolve as documented |
docs/operator-guide.md "Publisher liveness" |
Verified twice by taking real components down, against the rules as they now stand (all six load with health=ok). Publisher down: CEE stopped on 10.0.2.250 — CEEPublisherSilent went no-alert → pending (t+90s) → firing (t+210s), matching the 60s threshold plus for: 2m, and fired only for that publisher while the other two stayed green. This is the per-publisher discrimination the remote label exists for, demonstrated rather than argued. Exporter down: the exporter itself stopped — CEEExporterDown fired at t+120s while CEEPublisherSilent stayed silent for all three publishers, confirming the up{job="cee-exporter"} == 1 gate prevents a scrape failure from being misreported as every publisher dying at once. Both resolved within a minute of the component restarting. Not re-checked by CI |
Verified (manual) — measured end-to-end on 2026-08-11 |
The publisher-liveness series only cover publishers seen since the exporter started — a publisher already dead at startup has no series, and CEEPublisherSilent cannot fire for it |
docs/operator-guide.md "Publisher liveness" |
Nothing tests this; it follows from the peer table being in-process state that starts empty (pkg/metrics/metrics.go). The CEENoPublishers and per-publisher absent() rules in the operator guide are the documented mitigation, and neither is exercised by any test in this repo |
Unverified — a documented limit of in-process state, not a tested behaviour |
Writer batching and ingress bounding¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
Batching writes into one WriteBatch call per drain improves writer throughput |
CLAUDE.md (pkg/queue, pkg/evtx bullets); the underlying design is docs/superpowers/specs/2026-08-22-writer-batching-and-ingress-bounding-design.md |
make bench measured, on 2026-08-22 loopback runs of the permanent pkg/evtx benchmarks: GELF TCP 9.53 → 4.20 µs/event (~2.27x), syslog TCP 6.26 → 1.64 µs/event (~3.83x); EVTX is flat, 77.31 vs 70.81 µs/event batch=1 vs batch=100, because go-evtx fsyncs per chunk regardless of batch size — batching cannot remove that cost. GELF's smaller ratio is consistent with buildGELF (~5.1 µs) already sitting outside the batched lock and therefore not something batching can improve; syslog's larger ratio is consistent with batching removing two syscalls per event instead of one. These are loopback numbers, and they measure the wrong thing for the claim being made: the lock-hold time batching optimises is, in production, dominated by real network latency the loopback runs don't have, so whether batching moves the needle at all against a real collector is unmeasured — it could be larger or smaller than these ratios, in either direction. The bench job in .github/workflows/ci.yml runs these benchmarks, but only on workflow_dispatch, deliberately not on push or pull_request ("shared CI runners are too noisy for a threshold, and a flaky gate gets disabled, which is worse than no gate") — so nothing re-measures or gates this number going forward |
Unverified — the improvement is real on loopback and explained mechanistically, but nothing measures or gates the production case, and no CI job keeps even the loopback figures current |
Enqueue refuses new events after Stop instead of panicking on a closed channel, and counts the refusal as a drop |
CLAUDE.md (pkg/queue bullet); pkg/queue/queue.go |
pkg/queue/queue_test.go::TestEnqueueAfterStop calls Stop() then Enqueue(), and asserts Enqueue returns false (not a panic) and EventsDroppedTotal increments by exactly one |
Verified |
A partial batch — events accumulated but not yet at max_batch or batch_timeout_ms — is still written when the queue is stopped, rather than silently dropped |
docs/superpowers/specs/2026-08-22-writer-batching-and-ingress-bounding-design.md |
pkg/queue/queue_test.go::TestPartialBatchFlushedOnStop enqueues 3 events with the batch timer stubbed to never fire, calls Stop(), and asserts all 3 were written across the recorded batches. Verified by mutation: forcing the channel-closed branch to discard the pending events instead of flushing them changes the failure from "wrote 0 events across [], want all 3 flushed at Stop" back to pass only when the flush is restored |
Verified |
CEPA liveness traffic — RegisterRequest, HeartBeatRequest, and OneFS's heartbeat CheckFileRequest — is answered without waiting on the concurrency semaphore, so a saturated event queue cannot push a heartbeat past the 3-second CEPA deadline |
config.toml/config.toml.example [server] section; pkg/server/server.go (handshakeProbeBytes, acquireSlot) |
pkg/server/ingress_test.go::TestLivenessAnswersWhileEverySlotIsHeld occupies every slot with an event request parked in its body read, then PUTs each of the three liveness shapes and asserts a 200 inside 3 seconds. Verified by mutation: removing the exemption so every request calls acquireSlot fails all three subtests with "liveness request did not answer within CEPA's 3-second budget while a slot was held". TestOversizedLivenessStillTakesASlot pins the boundary — a RegisterRequest padded past handshakeProbeBytes is treated as an event payload and does take a slot, so the exemption cannot be used to bypass the memory bound; making readProbe always report the body complete fails it. The 3 seconds asserted here is the test's own deadline against a local handler, not a measurement against a real publisher over a network |
Verified (partial) — the exemption and its boundary are both proven and mutation-checked; the end-to-end heartbeat latency against a real CEE server is still unmeasured, as the dedicated row above says |
Request bodies are capped (max_body_mb, default 8 MiB) and concurrent in-flight requests are bounded (max_concurrent_requests, default 8), which bounds the request memory a burst of large CEPA PUTs can hold in flight to max_body_mb x max_concurrent_requests |
config.toml/config.toml.example [server] section, which also documents the measured 64 MiB body previously admitting ~190k events at ~4.2x body size in live heap (269 MiB) |
pkg/server/ingress_test.go::TestServeHTTPBoundsConcurrentRequests fires max_concurrent_requests+1 requests at ServeHTTP itself and asserts the last is held until a slot frees, and TestServeHTTPRejectsOversizedBody PUTs one byte over a non-default max_body_mb through ServeHTTP and asserts the body never reaches the queue and is counted on cee_events_dropped_total. An oversized batch is acknowledged, not 400'd: Dell CEE reads any reply that is not a well-formed response as a failed delivery and retries the same batch forever, so rejecting would convert one lost payload into a publisher stuck resending it. The drop is counted as one because the body was never parsed, so the true number of lost records is unknowable. Both build the handler with NewHandler from a LimitsConfig, so the config -> behaviour link is part of the assertion. Verified by mutation, five kills: deleting the acquireSlot/releaseSlot pair from ServeHTTP, releasing the slot immediately instead of deferring it, and sizing slots at 1,000,000 each fail with "ServeHTTP admitted request N+1 while every concurrency slot was held"; passing 1<<40 or a hardcoded 8<<20 as the body cap each fail with "EventsDroppedTotal = 0, want 1". All five survived the suite before these tests existed. pkg/server/server_test.go::TestBodyCapRejectsOversized asserts the default cap resolves to 8 MiB and that readBody rejects a body one byte over it, and TestSemaphoreBoundsConcurrency covers the acquireSlot primitive and the cee_requests_throttled_total count; both are primitive-level and neither proves ServeHTTP uses them, which is what the two tests above add. No test measures live heap under a real oversized-body burst — the 269 MiB figure is a prior manual measurement of the removed unconditional 64 MiB cap, not a number re-checked against the current 8 MiB default |
Verified (partial) — the cap and the concurrency bound are both proven to be enforced by ServeHTTP itself, not just configured and not just working as primitives; the live-heap consequence they exist to prevent is not re-measured under the current defaults. Note the scope: this bounds ingress-driven request memory, and does not promise the process cannot OOM — an operator may configure far larger limits, the queue and writers hold memory of their own, and liveness payloads are answered from a bounded prefix outside the semaphore (handshakeProbeBytes, 8 KiB per in-flight request) |
Build, packaging, and distribution¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
| Requires Go 1.27.2 | README.md, docs/index.md, docs/operator-guide.md |
go.mod:3 (go 1.27.2); the Go toolchain itself refuses to build against an older go if GOTOOLCHAIN=local and the installed version is too old (verified directly for the previous golang:1.24-alpine builder image during Task 3 — it failed with go: go.mod requires go >= 1.26.5) |
Verified |
Single static binary, CGO_ENABLED=0, no external runtime dependencies |
README.md |
Measured on the artifact, not argued from the build config. Every push: the static-binary job in .github/workflows/ci.yml builds make build-linux and asserts all four independent signals — file says statically linked, ldd says not a dynamic executable, readelf -d shows no NEEDED entry, and go version -m records CGO_ENABLED=0 — then runs a negative control: the same assertions against a deliberate CGO_ENABLED=1 build, which must come out dynamic. Without that control a broken grep would pass exactly like a clean binary — and the control is not hypothetical: run 31519959306 measured the release binary as statically linked with ldd reporting not a dynamic executable, and the control build in the same job as dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2. The job also failed its own first run, on the assertion rather than the binary: ldd exits 1 on a static binary, so piping it into grep under set -o pipefail failed the step on exactly the outcome being asserted. Every tag: verify-artifacts in .github/workflows/release.yml re-runs the checks against the published archives after checksum verification, since goreleaser's binaries are not the Makefile's. Measured by hand against the released v5.3.1 assets on 2026-08-11: linux_amd64 and linux_arm64 are ELF statically linked with no NEEDED entries, and all five artifacts — including windows_amd64 and both darwin builds — record build CGO_ENABLED=0. Scope correction: the darwin binaries are not statically linked, and cannot be — otool -L shows libSystem.B.dylib, libresolv.9.dylib, CoreFoundation and Security, which Go links on macOS regardless of CGO. The static half of this claim is Linux (and the scratch image built from it); the CGO-free half holds everywhere |
Verified — static-binary confirmed green in run 31519959306, with the control build measured dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2 in the same run; the shipped v5.3.1 artifacts were measured by hand. verify-artifacts first ran on v5.3.2 and failed on its own path handling, not on the release: ${archive%.*} strips only .gz, so the version-stamp step looked in a directory that did not exist. The v5.3.2 artifacts were correct — checksums verified, and the shipped darwin_arm64 binary serves cee_build_info{...version="5.3.2"}. Fixed in v5.3.3, where the job ran end to end and passed — run 31523204441: all six published assets checksum-verified, both Linux binaries measured statically linked, every archive carrying LICENSE, the shipped linux/amd64 binary serving cee_build_info{go_version="go1.26.5",version="5.3.3"} 1 and {"status":"ok"...} on /health, and ghcr.io/fjacquet/cee-exporter:5.3.3 resolving to linux/amd64 + linux/arm64 manifests |
Build-stamped version reaches the running binary (-X main.version=...) and appears in the cee_build_info metric, replacing the previous hardcoded "1.0.0" |
Not made by README.md or docs/PRD.md — corrected from an earlier draft of this row that cited both incorrectly. The actual claim is CLAUDE.md ("All build targets stamp the version: -ldflags "-X main.version=$(VERSION)"") and this release's own CHANGELOG.md [4.1.3] entry |
cmd/cee-exporter/version_test.go (TestVersion_DefaultIsDev, TestVersion_NotHardcodedRelease) only asserts the unstamped default is "dev" and isn't a hardcoded release string — no test builds a binary with -ldflags and inspects it. Now asserted automatically on every tag. verify-artifacts in .github/workflows/release.yml downloads the published linux_amd64 archive, runs the shipped binary, and requires cee_build_info{...version="<tag>"} on /metrics — failing explicitly if it reports dev, which is precisely what a lost -ldflags produces and what no unit test can catch (they only assert the unstamped default). The same step requires /health to answer "status":"ok", so a stamped-but-broken binary does not pass either. This job first runs on the next tag; measured by hand in the meantime on the released v5.3.1 darwin_arm64 artifact (2026-08-11): logs "version":"5.3.1" at startup and serves cee_build_info{go_version="go1.26.5",version="5.3.1"} 1, with /health returning status: ok — repeating the same result previously measured on v4.1.3 |
Verified — measured on the shipped artifact for two consecutive releases, and CI-gated from the next tag onward |
| Releases build for Linux and macOS on amd64 and arm64, and Windows on amd64 | docs/index.md, docs/PRD.md NFR Portability |
.goreleaser.yaml build matrix, with an explicit ignore: for windows/arm64. goreleaser build --snapshot resolves to exactly five targets — linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64 — and goreleaser check validates the config (verified 2026-08-09). Note the v4.1.3 release predates this change and did ship a windows/arm64 zip; the first release to match this row is the next tag. Windows/ARM64 was dropped deliberately: no target market (Windows Server does not ship for ARM64) and no CI runner to execute it, so it had been published without ever being run. The released artifact set now matches. v5.3.1 publishes exactly five binary archives — linux_amd64, linux_arm64, darwin_amd64, darwin_arm64, windows_amd64 — with no windows/arm64 zip, and every downloaded archive verified against the published checksums.txt (2026-08-11). go version -m on each confirms the GOOS/GOARCH pair the filename advertises, so the matrix is checked against binary contents rather than asset names |
Verified — build matrix and the released artifact set both, as of v5.3.1 |
Docker image published to ghcr.io/fjacquet/cee-exporter (multi-arch linux/amd64+linux/arm64) as part of the release |
README.md |
.goreleaser.yaml dockers_v2: block, invoked by release.yml on a v* tag. This mechanism is new on this branch and has never run as part of a real release. Task 3 verified the Dockerfile.goreleaser context resolution and image runtime behaviour by hand-assembling the same <goos>/<goarch>/<binary> layout goreleaser uses and building/running both architectures directly — both produced a working, correctly version-stamped image. What that did not prove was goreleaser's own orchestration of dockers_v2 (tag templating, manifest assembly, extra_files staging). The v4.1.3 tag has since run it. docker buildx imagetools inspect ghcr.io/fjacquet/cee-exporter:4.1.3 returns an OCI image index with linux/amd64 and linux/arm64 manifests, and :latest resolves to the same two digests. Note the published tag is 4.1.3, without the v — goreleaser's .Version strips the prefix (verified 2026-08-08, post-tag). Re-measured for v5.3.1 (2026-08-11): :5.3.1 is an OCI image index carrying linux/amd64 and linux/arm64 manifests plus their attestation manifest, and :latest resolves to the identical index digest (sha256:d94fb964…) — so the tag the README tells operators to run is the tag that was just released, not a stale one. verify-artifacts in .github/workflows/release.yml now asserts both platforms on both tags on every release |
Verified |
LICENSE (MIT) is present at the repo root |
README.md badge |
test -f LICENSE for presence; its first line reads MIT License, so the MIT designation is checked against content rather than assumed from the filename (verified 2026-08-09) |
Verified |
LICENSE is present inside every release archive |
Exit criteria for this release | .goreleaser.yaml archives.files lists README.md and LICENSE — but this is config, not an executed job. c792faa had previously removed LICENSE from the archive list because the release build failed on the missing file (LICENSE didn't exist in the repo at all until 313c815, on this branch); git show v4.1.2:.goreleaser.yaml confirms files: was README.md only at the last real release. No release had been cut since 313c815 re-added the line. The v4.1.3 release has now been cut and all six archives were inspected, not a sample: tar tzf on each of the four .tar.gz files and unzip -l on both .zip files each list exactly one LICENSE entry, and the extracted file's md5 matches the repo's LICENSE byte-for-byte (98ccd23e70491829e5a029fdb4a3e79f), so the archives carry the real MIT text rather than an empty placeholder (verified 2026-08-09, post-tag). Re-confirmed on all five v5.3.1 archives (2026-08-11), and no longer dependent on someone remembering to look: verify-artifacts in .github/workflows/release.yml now asserts a LICENSE entry in every published archive on every tag |
Verified |
cee-exporter.exe install registers the daemon with the SCM, and the resulting service actually starts and stops |
README.md, docs/operator-guide.md, docs/PRD.md DEPLOY-03 |
The windows CI job's "Service install/uninstall round-trip" step, run 31318610291: sc.exe query after install shows the service registered, then sc.exe start plus polling Get-Service confirms it reaches Running, and sc.exe stop confirms Stopped — exercising svcProgram.Start/Stop in cmd/cee-exporter/service_windows.go directly, not just kardianos's SCM registration helpers. Every native .exe/sc.exe call in the step is gated on $LASTEXITCODE |
Verified |
cee-exporter.exe uninstall removes the daemon from the SCM |
README.md, docs/operator-guide.md, docs/PRD.md DEPLOY-04 |
Same step, same run: uninstall prints its success message and is gated on $LASTEXITCODE, but the step does not re-run sc.exe query afterward to independently confirm the service is actually gone from the SCM — removal is confirmed by the tool's own success message and exit code only |
Verified (partial) |
| Windows Service auto-restarts after an unexpected crash | README.md, docs/operator-guide.md, docs/PRD.md DEPLOY-05 |
The windows CI job proves the SCM can start and cleanly stop the service (see the row above), but nothing simulates a crash or observes the OnFailure: "restart" recovery action in service_windows.go's svcConfig actually firing. Correct by inspection only |
Unverified |
go test ./... passes with zero failures |
docs/PRD.md Success Metrics |
Linux: CI ci job runs make test (go test -race ./...) on every push, currently green. Windows: the windows CI job's "Run tests" step runs the same command, confirmed using a real gcc (C:\mingw64\bin\gcc.exe on GitHub's windows-latest image) rather than silently degrading to the non--race fallback |
Verified on both platforms |
Linux systemd unit starts cleanly and is self-sufficient (no manual directory setup), including the tls_mode="acme" default cache path |
docs/operator-guide.md, deploy/systemd/cee-exporter.service |
No automated CI test — verified by hand for this release: booted the unit under real systemd (PID 1, DynamicUser=yes, ProtectSystem=strict) in a container with tls_mode="acme" configured. systemctl status showed active (running), the ACME challenge listener bound :443, and cee_exporter_ready logged. Entered the running service's own mount namespace (nsenter --target $MAINPID --mount) and, as its exact dynamic UID, successfully created and wrote /var/cache/cee-exporter/acme/test.pem — confirming CacheDirectory=cee-exporter makes the default acme_cache_dir writable with zero operator action, closing the gap Amendment A2 identified |
Verified (manual) |
Documentation and process¶
| Claim | Where stated | Verified by | Status |
|---|---|---|---|
| An operator can follow the README quickstart and see events in Graylog within 15 minutes | docs/PRD.md Success Metrics |
Nothing — no timed or automated check of this exists | Unverified |
docs/PRD.md links a docs/requirements.md that exists; the retired .planning/ directory no longer sits at the repo root |
Exit criteria for this release | test -f docs/requirements.md, test -d docs/archive/planning |
Verified |
The two specific retired phantom keys — type = "binary-evtx" and acme_staging — cannot silently return to docs/, README.md, config.toml, or config.toml.example |
docs/operator-guide.md, config.toml.example |
.github/workflows/docs-lint.yml no-phantom-config job, run on every push/PR. This checks only these two literal strings, not "no phantom config key can ever exist" — a brand-new undocumented-in-code key would not be caught by this job (the job's second step separately checks that five output type values have a case in buildWriter, which is narrower still). config.toml.example was silently unscanned until a 2026-08-08 review found the --include bug (GNU grep applies --include to explicitly-named files, not just directory walks) and it was fixed and re-verified with a real appended key. docs/adr/ADR-011-tls-certificate-automation.md remains whole-file exempt from the acme_staging check (its Consequences section is the legitimate historical content); and, within this file, any other new row that happens to name both retired strings on one line is exempted the same way this row is, by design — the guard cannot tell a second legitimate worked example apart from a real reintroduction sharing this row's own exemption trigger |
Verified |
make docs succeeds under --strict (no broken internal links) |
Development workflow, CLAUDE.md |
make docs (run locally as part of this release's verification; not yet re-run by CI after mkdocs.yml's copyright/nav changes in this release, since docs.yml only triggers on push to main) |
Verified (manual) — will also run automatically as docs.yml's CI check on merge to main |