feat(budget): sliding-window cost accounting for agent exchanges - #1
Draft
mfethe1 wants to merge 14 commits into
Draft
feat(budget): sliding-window cost accounting for agent exchanges#1mfethe1 wants to merge 14 commits into
mfethe1 wants to merge 14 commits into
Conversation
…block#5133) ## What Relay-only carve-out of the ingest half of block#4999: generic EVENT ingest now accepts kind:30179 (NIP-PMA private managed-agent config). One file, `crates/buzz-relay/src/handlers/ingest.rs`, 16 insertions / 15 deletions; **two semantic lines**, byte-identical to the ingest hunk of block#4999 at `6f486e88`: 1. `required_scope_for_kind`: 30179 requires `Scope::UsersWrite` — same arm as its public sibling 30177 and the other owner-authored NIP-AP kinds. 2. `is_global_only_kind`: 30179 is owner-global, keyed `(pubkey, kind, d-tag)`; a stray `h` tag must not channel-scope it. The rest is import reflow plus replacing the guard test with a positive one (`private_managed_agent_kind_is_owner_scoped_global_user_data`: asserts UsersWrite scope, global-only, no h-channel scope). ## Why the guard test can be retired The removed test (`private_managed_agent_kind_remains_rejected_until_atomic_ingest_exists`) pinned a stated precondition: *"must not enter generic EVENT ingest before privacy and aggregate CAS deploy."* Both halves are resolved: - **Privacy** — the author-only read gates for 30179 shipped to main with block#4593: `AUTHOR_ONLY_KINDS` membership, `req.rs` pre-filter + result gates, `count.rs`, `event.rs` fanout, and the bridge pre-filter (`bridge.rs:999-1000` returns `restricted: author-only kinds require authors=[self]` / 403). Only the author can read the event back. - **Aggregate CAS** — block#4999 settled generation as **advisory**: the `g` tag is shape-validated, never relay-enforced. Last-write-wins per coordinate is the contract of record (see the kind:30179 contract blurb in block#4999), so no CAS mechanism is pending on the relay side. ## Why this is inert to existing relays and clients - No production desktop code on main authors kind:30179 — the codec (`private_managed_agent.rs`) has zero non-test callers. This PR accepts a kind nobody can produce yet. - Content is opaque NIP-44 ciphertext to the relay; the relay never decrypts it. - Reads remain author-only via the already-shipped gates above. - Storage is the standard parameterized-replaceable path already exercised by kinds 30175–30178. No schema, config, or migration changes. ## Testing - Full `buzz-relay` package suite at this commit: 859 passed, 1 failed — `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` (504 vs 200), which **reproduces identically on clean main `769ac70b`** with this change stashed; pre-existing/environmental, not introduced here. - New positive ingest test passes. - Pre-push hooks green (branch-skew, rust-tests, desktop-tauri-checks). ## Relationship to block#4999 block#4999 (relay-primary agent config, desktop half) stays DO-NOT-MERGE pending live relay receipts + real CI; once this lands and deploys, its live test simplifies to plain `desktop-standalone` against the real relay, and block#4999 rebases to drop its now-duplicate ingest hunk (identical bytes → trivial rebase). Originating thread: buzz://message?channel=06f13ed3-0557-4ac2-922c-1545dd00bf97&id=2a43b3b4933a2ea78b77088619251c061355f9b7b6dc29ea0d702193f2344149 ## Brownfield FTS note (review findings, operator-ruled non-blocking for this PR) Max and Sami independently identified that the FTS privacy skip-set is regime-dependent: migration 0008 installs the positive allowlist (`kind IN (0, 9, 40002, 45001, 45003)`) **only on an empty events table**; an already-populated database keeps the 0001/0005 negative skip-list (wrapped by 0014 to add 30350), which omits 30179 — so on such an installation this PR admits 30179 rows whose NIP-44 ciphertext gets indexed by `to_tsvector`. Sami measured both regimes against real Postgres (brownfield: 30179 INDEXED; fresh: NULL) and demonstrated the existing drift test only exercises the fresh regime. `schema/schema.sql:222`'s canonical literal is also the negative list and omits 30179. Migration dates put any relay deployed with data before 0008 landed (2026-07-13) in the brownfield class. **Scope of exposure (Sami's trace):** not a content leak — `event_visible_to_reader` / `is_author_only_event` gates hold on both search surfaces (`req.rs:725`, `bridge.rs:1770`), so foreign readers receive nothing. Lost is the storage-level NULL-tsv backstop plus FTS page budget burned on post-filtered hits. **Operator ruling (Tyler, events `1472e5b6`, `cbd368ed`):** ship this PR without an exclusion migration. Safety argument that makes this sound rather than merely accepted: main has **zero non-test 30179 writers** until block#4999's desktop half deploys — no 30179 rows can exist, so nothing can be indexed in any regime while this PR is the only half live. **Additional review characterizations (Sami, non-blocking, on the record):** - *Behavioral delta enumerated:* routing triple (`required_scope_for_kind` / `is_global_only_kind` / `requires_h_channel_scope`) compared for all 65,536 kinds at base `769ac70b` vs head `77eeba6e` — exactly one row differs (30179). No other kind or client changes behavior. - *"SQL visibility before LIMIT" (NIP-PMA step 2):* no `AUTHOR_ONLY_KINDS` pushdown clause exists in `buzz-db` (only `SHARED_GATED_KINDS` has one). Author-only kinds are protected by the pre-filter (`author_only_filters_authorized`) plus post-filter omission; mixed-kind filters can burn candidate-page budget on discarded rows. Pre-existing and identical for 30300/30350 — not introduced here; noted so the NIP's step-2 checkbox is not read as fully ticked. - *Envelope validation gap:* 30179 is the only parameterized-replaceable kind at ingest with no per-kind envelope validator (codec grammar checks run in the desktop writer, not the relay). Generic limits only (256 KiB, ±15 min, pubkey==identity, d-tag bound). Self-inflicted footgun bounded to the author's own coordinate — candidate companion to the exclusion migration in the block#4999 rebase, deliberately not added here. **Bound follow-up (required before/with the block#4999 desktop half):** a 0014-shape additive migration (`pg_get_expr` capture + `CASE WHEN kind = 30179 THEN NULL ELSE (<existing>) END` wrap), add 30179 to the `schema/schema.sql:221` literal, and a brownfield-regime variant of the FTS drift test, per Sami's finding. Deploy-time spot check if ever wanted: `SELECT pg_get_expr(d.adbin, d.adrelid) FROM pg_attrdef d JOIN pg_attribute a ON a.attrelid = d.adrelid AND a.attnum = d.adnum WHERE d.adrelid = 'events'::regclass AND a.attname = 'search_tsv';` Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
…lock#5136) ## Problem The harness posts each trial's task via `buzz messages send`, relying on `@<orchestrator-id>` name resolution. Task text is untrusted payload: when it contains @-tokens of its own, the CLI's mention resolver tries to resolve them as channel members, fails, and refuses to send — killing the trial with `RuntimeLaunchError` before the agent ever saw the task. Live occurrence: TB 2.1's `large-scale-text-editing` task embeds Vim macros (`:%normal! @a`). In the tb21-solo-1 run the trial died at launch: ``` RuntimeLaunchError: buzz messages send ... exited 1: {"error":"user_error","message":"mention '@A' does not match a current channel member; retry with --mention <pubkey>"} ``` Any TB task whose statement contains @-syntax is silently zeroed this way. ## Fix Pass the orchestrator's pubkey as an explicit `--mention` when posting the task. The CLI demotes unresolved @-tokens in the text to presentation-only when any explicit identity is supplied, so delivery still targets exactly the orchestrator and every @-token in the task statement becomes inert. The harness already holds the orchestrator's `AgentCredential` (it writes that pubkey into the worker roster tables), so no persistence is needed — fresh key per trial, fresh `--mention` per trial. Verified both halves against a live relay: a fenced `@a` without `--mention` still hard-fails (the resolver is not markdown-aware); the same content with an explicit `--mention` sends clean with `mention_pubkeys` containing only the target. ## Testing - `benchmarks/harbor-buzz-orchestra`: full pytest suite — 35 passed (34 baseline + new `test_send_mentions_by_pubkey_so_task_text_stays_inert`), ruff clean. Run against `origin/main` 769ac70 with exactly this patch applied. - `testbed`: full pytest suite — 23 passed, 1 skipped; ruff clean. ## Acceptance A task statement containing arbitrary @-tokens (Vim registers, emails, decorators) launches and delivers to the orchestrator instead of dying in `_send`. Originating Buzz thread: `buzz://message?channel=c3252dd2-0142-4e01-88c7-a2183c3960a5&id=74a65a0990fd2197882b66b5ea2707169d4a3dbd2020d1610c45150fb99f140b` Signed-off-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@buzz.block.builderlab.xyz>
…uth extraction fails (block#4824) emit structured JSON diagnostics when NIP-OA owner-auth extraction fails during `buzz agents archive`/`unarchive` ## Problem When owner-auth extraction returned `None`, the CLI silently sent a bare request. The relay replied with `400: missing auth tag` and the caller had no way to know why extraction failed. ## Solution Extract `resolve_auth_from_profile` — a sync function that owns all three warning branches and the success path. `resolve_auth` reduces to: self-check → fetch kind:0 → delegate. - **Four distinct diagnostics**: no kind:0 profile / no tags array / `classify_owner_auth_tag` failure (typed `AuthFailure` enum: `NoAuthTag`, `AmbiguousAuthTag`, `WrongArity`, `NonStringElement`, `InvalidOwnerHex`, `InvalidSigHex`, `OwnerMismatch`) - **JSON format**: each fallback emits exactly one `{"warning":"..."}` line to stderr, matching the CLI's documented structured-stderr contract and the precedent in `channels.rs:597` - **Relay-supplied values** (target pubkey, actual owner pubkey) pass through `serde_json` serialization — no unescaped text - **Admin bare path preserved**: request is always sent after the warning; bare non-self requests are legitimate for relay admins - **Self path unchanged**: silent, no relay query ## Boundary tests Tests call `resolve_auth_from_profile` directly with `&mut Vec<u8>`. Each of the three production `writeln!` calls is covered: deleting any one fails at least one test. Success path asserts zero bytes written. ## Changes `crates/buzz-cli/src/commands/agents.rs` only: - `AuthFailure` enum with `message()` formatter - `classify_owner_auth_tag` returning `Result<[String;4], AuthFailure>` - `extract_owner_auth_tag` reduced to `#[cfg(test)]` `.ok()` wrapper - `resolve_auth_from_profile` sync helper (testable without `BuzzClient`) - `resolve_auth` reduced to self-check + fetch + delegate - 9 new boundary tests replacing the prior test-local helper --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
## Summary - move **Run on** into Advanced, directly after **Who can send instructions** - reuse the modal’s shared dropdown styling - give the Welcome guidance and composer matching glass treatment while preserving the corrected exit layering ## Validation - `pnpm -C desktop typecheck` - focused Playwright: Run on configuration (3 passed) - focused Playwright: Welcome onboarding flow (1 passed) - desktop unit suite (4,290 passed) --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Signed-off-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz> Co-authored-by: Watcher <bb7abfd757d0af7b66569d02ab9c0316b616f9d0c151ecf5b964344c462e7f8f@buzz.block.builderlab.xyz>
   --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
Implements the accounting half of the loop-protection policy decided in wayfinder ticket block#7 (mfethe1/agent-mesh#7). Buzz already has throttles — thread depth capped at 100, tiered rate limits, in-flight deadlines, session rotation — but nothing that says an exchange is finished. Two agents that open fresh threads at each other never accumulate depth, so they run at a rate-limited 120 messages a minute indefinitely, each one a paid LLM call. This crate budgets the thing that actually matters: measured cost_usd from the kind 44200 turn metric, $5 per (channel, agent-pair) per rolling hour. The window slides, so it self-heals with no human reset. Human-triggered turns are never charged — cutting off someone who is sitting there watching is the failure the policy exists to avoid. Deliberately does not enforce. record() returns a Verdict and the caller decides. Buzz's owner commands (!shutdown, !cancel, !rotate) cannot mute a single peer, so the enforcement mechanism is still open; keeping the accounting pure lets that decision land without touching this code. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
Decision D5 of wayfinder block#7 says only agent-triggered turns consume budget. Implementing it surfaced a gap: the kind 44200 turn metric has no field naming the author that caused the turn. Its payload is harness, model, channel_id, session_id, turn_id, turn_seq, timestamp, turn, cumulative, delta_reliable and stop_reason — nothing about the trigger. TriggerLog recovers it without a wire-format change, using a property buzz-acp already guarantees: turns are serialised per channel, and all pending events for a channel drain into one batch. So the messages seen in a channel since its last turn are that turn's trigger. A batch mixing human and agent messages counts as human. D5 exists to protect the case where a person is present and watching; charging that turn risks muting an agent mid-conversation with its owner. Erring the other way costs a runaway one extra turn before it trips. Attribution is best-effort and fails open: an observation gap yields None, which is not charged, so a dropped subscription disables the budget rather than tripping it. The durable fix is a trigger field on NIP-AM, which the spec's forward-compatibility rule already permits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
Ledger charges a pair; TriggerLog attributes a turn. Held separately, a caller has to remember to attribute before charging and to prune both on the same schedule. Supervisor owns that sequencing so it cannot be got wrong, and gives the crate one entry point instead of two loose halves. Adds the integration coverage the pieces lacked. The end-to-end runaway now has a test: two agents alternating at $0.30 a turn trip the $5 budget on the seventeenth charged turn. So does the failure this policy must never cause — two hundred consecutive owner-driven turns at $1.50 each, none blocked, nothing charged. Also covers a human joining a runaway mid-flight, which makes that turn free because the mixed batch attributes to the human; separate pairs in one channel not pooling budget; and an unobserved turn failing open. Writing the runaway test caught a real arithmetic error in its own expectation, not in the code: $5 at $0.30 a turn first exceeds budget on the seventeenth charge, not the eighteenth. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
The crate could account and attribute, but nothing connected it to real data. charge_from_metric decrypts a kind:44200 event with the owner's keys and extracts channel, agent, cost and end-of-turn timestamp. Kept in its own module because it is the only part of the crate that knows about Nostr. Ledger and TriggerLog stay testable on plain values with no keys and no events, which is why they have the coverage they do. Tests build real signed events with buzz-core's own encrypt helper and round-trip them, so this is checked against Buzz's actual wire format rather than a hand-rolled fixture. A stranger's key fails closed, an unrelated kind is rejected before any decrypt attempt, and missing or malformed channel ids and timestamps are errors rather than silent defaults. Notable while writing it: decrypt_agent_turn_metric already rejects negative and non-finite costUsd per NIP-AM, so the ledger's own guard against a refund is defence in depth rather than the only barrier. delta_reliable is surfaced on TurnCharge rather than swallowed — block#7 OQ7.1 asks how often it is false, and that cannot be answered if the ingest layer discards it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
Two real bugs, one security and one correctness, plus the documented standards this crate was skipping. Security: charge_from_metric took turn_end from the agent's own encrypted payload and never checked it. AgentTurnMetricPayload::validate covers only the numerics, so a metric dated in the future moved the ledger's eviction cutoff forward and wiped the pair's whole window — the budgeted agent could zero its own budget at will. The payload timestamp is now tied to the signed created_at within a 300s tolerance. Correctness: TriggerLog consumed the batch per channel rather than per agent, so with two agents in one channel whichever metric was processed first ate the other's trigger and the second turn went uncharged. Relay reordering alone disabled the budget. Consumption is now tracked per (channel, agent) with independent cursors. The spec axis found a third defect that per-agent cursors do NOT fix: the metric's timestamp is end-of-turn and the payload carries no start, so a message arriving mid-turn is attributed to the turn it did not trigger, and one owner message can free two turns. It errs toward under-charging like every other approximation here, and the durable fix is a turn-start or trigger field on NIP-AM. Documented rather than papered over — it is a second argument for block#7 OQ7.5. Both reviewers independently flagged Verdict::Allow{spent_usd: 0.0} on human and unattributed turns as a lie: the pair may hold $4.90, and a caller logging spend would see a sawtooth already collapsed to zero. Replaced with an explicit Unbudgeted variant. Standards: adds deny(unsafe_code) and warn(missing_docs) per CONTRIBUTING.md, documents the public API those lints then surfaced, and registers the crate in the AGENTS.md crate map. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
The spec-review agent cited agent_turn_metric.rs:107 for the end-of-turn timestamp and I carried that into origin.rs without opening the file. Line 107 is turn_seq documentation; the RFC 3339 end-of-turn line is 111. Every other cross-crate citation in the crate was re-checked by opening the cited line: buzz-acp/src/lib.rs:192 (is_owner_or_sibling), buzz-acp/src/queue.rs:1-7 (per-channel serialisation), agent_turn_metric.rs:86 (forward-compatibility rule) and :1-5 (NIP-44 addressing). All four hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
Two gaps the spec review found, both now closed. D4 justifies a self-healing window by the sawtooth it leaves in the logs — "a diagnosable signature rather than a silent drain" — but the crate had no tracing dependency and emitted nothing, so the signature the decision rests on did not exist. Exhaustion now logs at warn with the pair, spend and budget; ordinary charges log at debug. delta_reliable was populated by the ingest layer and read by nobody, so D2 charged unreliable deltas at face value with no seam for the fallback OQ7.1 asks about. on_turn_charge takes a wire-decoded TurnCharge, warns when the publisher lost its cumulative baseline, and still charges — because OQ7.1 is genuinely undecided and its suggested fallback needs cumulative, which TurnCharge does not yet carry. Explicit and logged beats silent, and it lets the question be answered from real data. Both behaviours are tested rather than left accidental. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…sing The verification gate is right that tests are not runtime evidence. This example launches the supervisor with a real tracing subscriber and shows what an operator would actually see. Two of D4's claims hold under observation. Forty consecutive owner-driven turns at $2.50 each: zero blocked, zero charged. After the window slides, spend resets and the verdict returns to Allow with no reset step. The third does not. D4 justifies the design by predicting "a runaway stops within minutes and restarts only to stop again, producing a sawtooth in the logs — a diagnosable signature rather than a silent drain." Running it produces no sawtooth: spend climbs monotonically from $5.40 to $18.00 across 22 consecutive WARN lines, because nothing acts on the verdict. The sawtooth is a property of enforcement, not of accounting. Until an enforcer exists the signature is a continuous alarm, which is arguably worse for diagnosis than the silent drain it was meant to replace. That is a flaw in the decision's reasoning rather than in this code, and it is recorded on the ticket. spent_usd is logged raw rather than rounded. The float noise is real data and these are structured events meant for machine aggregation; rounding at the emit site would lose precision for no gain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
The spec-review agent flagged it as unrequested; spot-checking that claim found exactly one caller, and it was the test asserting on it. A public accessor whose only consumer is its own test is not API, it is speculative generality. The test it existed for still matters — both argument orders must reach one budget — so it now asserts that directly by hashing both keys into a set, which is closer to how PairKey is actually used. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013okf5qYe8U8zja6wPBAL2y
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements the accounting half of the loop-protection policy decided in wayfinder ticket mfethe1/agent-mesh#7.
Draft — this crate has no consumer yet, by design. It stops at a seam; see Deliberately not implemented.
Why
Buzz already has throttles — thread depth capped at 100, tiered rate limits (human 60 / agent 120–600 msg/min), in-flight deadlines, session rotation. It has no termination condition. Two agents that open fresh threads at each other never accumulate
thread_metadata.depth, so they run at a rate-limited 120 messages a minute indefinitely, each one a paid LLM call.What
crates/buzz-budgetbudgets the thing that actually matters: measuredcost_usdfrom thekind:44200turn metric (NIP-AM).Four modules:
ingest(wire → values, the only part that knows Nostr),origin(attributes a turn to its trigger),Ledger(the sliding window),Supervisor(composes them so a caller cannot get the order wrong).Deliberately not implemented
Enforcement.
record()returns aVerdict; acting on it is the caller's job. Buzz's owner commands are!shutdown(kills the whole harness),!cancel(one turn) and!rotate(session) — none can mute a single peer, so enforcing this needs a newbuzz-acpcommand. Keeping the accounting pure means that decision can land without touching this code.Also absent: the relay subscription loop, which needs an enforcement host (ticket block#7 OQ7.3).
Known limitations, carried honestly
cost_usdis invisible to the budget (Improve message markdown display and formatting block/buzz#7 OQ7.2).Review
Both axes of
/code-reviewran against this branch and found two real bugs, since fixed in72d86f0:turn_endwas taken from the agent's own encrypted payload and never checked, so a future-dated metric wiped the pair's window. A runaway could zero its own budget. Now tied to the signedcreated_at.Verified at runtime, not just tested
cargo run -p buzz-budget --example runawaydrives the supervisor with a realtracingsubscriber. Two of the policy's claims hold under observation, and one does not:Allow✅WARNlinesThe sawtooth is a property of enforcement, not accounting. Until something acts on the verdict, the "diagnosable signature" D4 promised is a continuous alarm — arguably worse than the silent drain it was meant to replace. That is a flaw in the decision's reasoning rather than in this code, recorded as #7 OQ7.6.
Checks
41 tests + 1 doctest passing.
cargo clippyclean,cargo fmt --checkclean.#![deny(unsafe_code)]and#![warn(missing_docs)]per CONTRIBUTING.md.