v0.9.25
What's new in v0.9.25
v0.9.25 makes a published carbon figure mean the same thing wherever it was produced, and makes a report say what it cannot show instead of showing nothing. Three [green] settings that changed the numbers without appearing anywhere in them are deprecated, the total is split into the part an anti-pattern fix can reduce and the parts it cannot, and the archived windows a disclosure is aggregated from now carry their own integrity chain. On the reading side, every absent figure names its cause and every finding type, data tab and carbon card can explain its own method.
Three settings that moved the figures and left no trace in them
[green] network_energy_per_byte_kwh is deprecated and ignored. A configurable coefficient let two disclosures scale the same cross-region traffic differently, and setting it to zero erased the term with nothing to show for it, which is the opposite of counting transport on every run. It is fixed at 0.04 kWh/GB. Published values for this coefficient span two orders of magnitude, so instead of a fabricated interval the disclosure now carries a sourced bracket beside the mid value: 0.001 kWh/GB, the Cloud Carbon Footprint networking coefficient for inter-datacenter optical networks, and 0.059 kWh/GB, the Sustainable Web Design Model v4 operational network segment, which includes access networks and is a ceiling.
[green] include_network_transport goes the same way. Once the term is computed and disclosed on every run, a toggle that only hid it in the dashboards has nothing left to justify it, and two organisations whose reports disagreed on the setting were never comparable to begin with.
[green] embodied_carbon_per_request_gco2 = 0.0 falls back to the default. No hardware has zero embodied carbon, so a zeroed coefficient erased the SCI M term from a public figure while looking like a measurement. The coefficient stays configurable, because fleets genuinely differ and the applied value is published, but zero stops being a way to opt a term out of a disclosure. An absent embodied_kgco2eq now has exactly one meaning, no green-scored window in the period.
All three warn rather than refuse, and every key still parses. An upgrade must not turn a running daemon into a boot failure.
The total says which part is addressable
aggregate.carbon_breakdown splits total_carbon_kgco2eq into its operational, embodied and transport terms. A disclosure exists to state avoidable waste, and only the operational term carries the avoidable share the waste tiers quantify. Amortised hardware and cross-region transport are irreducible by construction, so reading the single total overstated the addressable footprint by whatever those two weigh, with no way to tell. The three sum back to the total.
A term with nothing behind it is omitted rather than published as zero, because a zero cannot distinguish "not measured" from "measured at zero". The transport bracket follows a stricter rule than the term itself: it frames the fixed coefficient, so it is withheld as soon as one window of the period was scored under a different one. Windows archived before this release declare no coefficient at all, and that absence disqualifies the period rather than being read as the default. On a mixed or older archive you will therefore see transport_kgco2eq with neither bound beside it, which is the intended answer and not a regression.
Two figures that existed but were never published join them: database_kgco2eq_out_of_total and messaging_kgco2eq_out_of_total. Both subsystems reported their waste and never their total, so the only carbon a reader could see for them was the avoidable part. They stay outside the sum, a database cgroup and a broker are a different perimeter from the instrumented services, but every carbon figure a run produced is now in one place.
The coefficients that scale all of it are recorded in each archived window and published under calibration_inputs.scoring_coefficients, folded from the windows rather than read from whichever machine happens to run disclose, which is the only correct source when the period was measured elsewhere. A coefficient changed mid-period publishes both values instead of one.
The schema moves to perf-sentinel-report/v1.6, additive with one deliberate exception: integrity.trace_integrity_chain has been emitted since v1.0 and keeps being emitted, null when there is nothing to describe. Dropping the key when absent would have changed the canonical bytes of every already-published report and failed its verify-hash.
The windows a disclosure is built from carry their own integrity
Until now the integrity story stopped at the published document. The content_hash and the cosign signature proved a report had not moved since generation, while the NDJSON windows it was aggregated from carried nothing at all, so editing one before running disclose produced a perfectly coherent report under a valid hash.
Each archived line now records the hash of {ts, report, prev, seq}, the hash of its predecessor and its position, which makes an edited, removed or reordered window detectable. A line whose hash field is deleted counts as a break once the file's chain has started, otherwise stripping the field would have been a way to rewrite a window and still publish zero breaks. disclose verifies while aggregating and publishes the verdict under integrity.trace_integrity_chain: windows verified, windows written before chaining existed, breaks inside the period and breaks in the same files outside it, since one rolling archive can span several periods and a 2024 edit must not be published as this quarter's break.
A break is published rather than fatal, so a truncated archive or an old rotation still yields an honest partial report, and the field sits under the content_hash precisely so a break count cannot be reset to zero under a valid signature. Writes are unbuffered and a failed one cuts the file back to its last complete window, so a disk error or an ungraceful kill surfaces as a dropped window and never as an accusation of tampering. A restarted daemon resumes the chain by scanning to the last chained window, a linear read chosen so that no record-size ceiling can silently truncate one.
The edit no chain can see on its own is a clean truncation of the file's tail, which leaves a shorter but self-consistent chain that seq cannot contradict either. Detecting it needs a chain head kept outside the file, which is what integrity.cross_period_log stays reserved for, and the schema says so next to the field. This is tamper-evidence after the fact, not proof of a sincere source.
An absent figure names its cause
The CLI report, the operator TUI and the HTML dashboard used to drop a carbon, database or broker line they had nothing to fill. A missing line reads as a feature that does not exist, and the operator has no reason to look for the setting that would produce it. All three now render it greyed with the reason next to it, and the reason is read from the analyzed trace count, the only honest discriminant: a daemon can carry a scoring configuration and still have green scoring switched off.
The dashboard sidebar does the same with its data tabs. pg_stat, mysql_stat, Diff and Correlations stay in place, greyed, with a ? naming what would enable them, instead of vanishing from a report that has no such data.
The dashboard explains its own method
Every finding type, data tab and carbon card carries a help icon. Hovering gives a one-line summary, clicking opens a plain-language method sheet, 21 of them, embedded in the file like everything else, each stating what the detector or the figure actually measures, what it does not, and how to act on it. The sheets inline the thresholds of the run being read rather than the defaults, so a number in the sheet is the number that produced the findings on screen, and they link to the published methodology for the rest.
That required the report to carry them. Report.detection_config now records the detection settings of the producing run. It is additive, and a shape this binary cannot read degrades to absent rather than to the current defaults, so a dashboard never shows a threshold that did not produce the findings beside it.
The rest of the pass is reading comfort. Findings that describe a whole trace highlight the spans they blame. The diff columns and the correlation list sort by descending severity, and the new-findings zone is tinted by its worst. Each side of a correlation opens its own finding rather than both landing on the same one. Clicking a finding scrolls its detail back into view, and the shortcuts footer is pinned like the topbar on viewports taller than 700 px, where pinning does not eat the content.
One more case of a report saying nothing: served under a policy that blocks its script, the file rendered as a blank page with no tab, no row and no value and nothing anywhere explaining why. It now opens with a plain unstyled notice naming both causes, JavaScript turned off in the browser and a policy sent by the server, which a script placed immediately after removes during parsing, so a normal load never paints it. It detects nothing and names no vendor, it is simply what remains when the script does not run.
Configuration in ordered fragments
Configuration can be split under .perf-sentinel.d/, with NN-lowercase-name.toml names, unique priorities, recursive table merging, later values winning, and the main .perf-sentinel.toml loading last. The GreenOps fragments ship ready to copy under standardized names, one per energy backend plus one for Electricity Maps.
An invalid file now stops the process with exit code 75 instead of discarding the valid files around it and continuing on defaults, and the same rule now covers the implicit .perf-sentinel.toml found in the working directory, which used to warn and carry on. A config that fails to read or parse stops the run whether it was passed with --config or discovered.
Capture on a clean workspace
perf-sentinel capture --output target/traces.json -- mvn verify failed on the first run of a fresh CI workspace, because Maven is what creates target/ and Maven is the command being wrapped. Since capture binds its ports and opens its file before spawning, deliberately, so that a capture which cannot listen never leaves a test suite running detached, refusing there meant the integration suite never ran at all. The parent directory is now created. The refusal stays for what creating a directory cannot fix, an unwritable path still fails before the command starts.
The mirror image is mvn clean verify: clean removes target/ after the file is open, the writer keeps filling an inode no path points at, and the run used to print a span count and exit 0 while nothing existed at the path the next step reads. The end of the run now checks and fails. On Windows the file identity used for that check no longer depends on an unstable API.
The configuration surface is documented in full
examples/perf-sentinel.toml carried 14 of the 31 keys [daemon] accepts, and an unknown key under [daemon] is ignored in silence, so a name recalled from memory reads as a code bug rather than a config mistake. Every key the parser accepts now appears with its default, its bounds and its startup-warning band, and a test fails the build when a config field exists without being shown there. The audit that test replaces found three examples that did not parse, six annotations wrong against the code, and five [reporting] keys presented as working settings that nothing reads, now marked reserved.
docs/INSTANCE-TYPES.md lists the 356 instance types carrying an embedded power profile with their idle and max wattage, generated from the embedded table and pinned by a test, so an operator can check whether their own type resolves to a measured profile or to a provider average.
Upgrade impact
One figure moves. A period previously scored with include_network_transport off now reports a larger total, once, because the term it was hiding is counted. That is the setting's removal working as described.
One figure appears less often than the term it frames. On an archive built before this release, or a mixed one, transport_kgco2eq is published while transport_kgco2eq_low and _high are absent. A bracket is only published for a period whose every contributing window declares the fixed coefficient.
One start-up behavior changes. An implicit .perf-sentinel.toml in the working directory that fails to read or parse now exits 75 instead of warning and running on defaults. A daemon that was quietly ignoring a broken config file will stop instead.
The three deprecated [green] keys still parse and warn, so no configuration needs editing to upgrade. For downstream crates, GreenConfig::include_network_transport, GreenConfig::network_energy_per_byte_kwh and the two CarbonContext fields behind them stay on the public API marked deprecated, since 0.9.24 -> 0.9.25 is a cargo-compatible upgrade and deleting a public field would break a build on cargo update. They now read what scoring applies rather than what the file asked for, so code still using them computes the same figure as the engine. Report gains an optional detection_config.
No detector, threshold or exit code of an existing subcommand changes meaning.
Why this is a patch and not a minor
The pre-1.0 scheme bumps the third number for features too. Nothing is removed or renamed: three configuration keys stop having an effect but keep parsing with a warning, four public Rust fields stay in place marked deprecated, and the schema addition is additive with the one already-emitted field deliberately kept in the output. A pipeline that ignores all of it behaves as it did under 0.9.24, apart from the carbon total noted above.
Validation
Validated in the external simulation lab, PASS recorded in release-gate/lab-validations.txt. The gate ran a targeted subset rather than the full scenario set, on the grounds that ingest/ and normalize/ have no file touched since v0.9.24 and detect/ carries serde derives only, which was checked against the diff before the subset was accepted. Two of the items it raised are fixed in this release: the main configuration file was called a fragment in parse errors, and --output target/... under mvn clean is now documented as incompatible by construction as well as detected.
The full Rust suite passes, clippy is clean at -D warnings in the default configuration and with the daemon feature, and the default and --no-default-features builds both pass.
Verifying this release
# Binary integrity via SLSA Build L3 attestation
gh attestation verify perf-sentinel-linux-amd64 \
--repo robintra/perf-sentinel
# A periodic disclosure produced by this binary
perf-sentinel verify-hash --report perf-sentinel-report.json \
--expected-identity "https://github.com/robintra/perf-sentinel/.github/workflows/release.yml@refs/tags/v0.9.25" \
--expected-issuer "https://token.actions.githubusercontent.com" \
--verify-binary ./perf-sentinel-linux-amd64gh CLI 2.49 or newer required for gh attestation verify.
Full Changelog: v0.9.24...v0.9.25