From c6c7607eb75a70febf05fd228257e034f32505de Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:15:04 -0400 Subject: [PATCH 1/2] fix(rate-limit-guard): narrow the reader contract's account forward-pass claim to what the filter does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reader contract described the tee writer's forward-pass filter accurately and then drew a conclusion broader than the filter supports. Two bullets promised that the release adding an account identifier "upgrades this file without a plugin change" / "costs no plugin change". The filter is `to_entries` over the root object only, with `test("account"; "i")` matching a substring of the key, and it has no else branch. The promise holds only for a field that is both top-level and `account`-named; a nested field (`user.account_uuid`) or one named `user`, `identity`, `org`, or `seat` is dropped, and dropped silently — in a contract that fail-closes on every other unresolvable input. Both bullets now state the filter's actual reach, name the silent drop, and scope the no-change claim to that one shape. The tee-shape bullet owns the statement; the invariant bullet points at it instead of restating it. `statusline-tee.sh` is unchanged — widening the filter is a design question owned by TODO(#1218). Closes #1685. Co-Authored-By: Claude Opus 5 (1M context) --- .../.claude-plugin/plugin.json | 2 +- plugins/rate-limit-guard/CHANGELOG.md | 19 +++++++++++++++++++ .../reference/reader-contract.md | 15 ++++++++++----- 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/plugins/rate-limit-guard/.claude-plugin/plugin.json b/plugins/rate-limit-guard/.claude-plugin/plugin.json index e2e7185bd..020f3af39 100644 --- a/plugins/rate-limit-guard/.claude-plugin/plugin.json +++ b/plugins/rate-limit-guard/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "rate-limit-guard", - "version": "0.3.4", + "version": "0.3.5", "description": "Shared rate-limit guard for loop lanes: a statusline wrapper tees the subscription rate-limit windows to a fixed machine-scope file, a StopFailure hook records rate-limit stops reactively, and a reader contract fixes how consuming sessions pause and resume.", "author": { "name": "Melodic Software", diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index c8e35ee49..ec373e769 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,25 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this plugin uses semantic versioning. +## [0.3.5] + +### Fixed + +- **Reader contract: the tee's `account` forward-pass no longer promises a no-change upgrade path it + cannot deliver (#1685).** Two bullets — the tee-shape field list and the single-account gap + invariant — described the writer's filter accurately and then drew a conclusion broader than the + filter supports: that the release adding an account identifier "upgrades this file without a plugin + change" and "costs no plugin change". The filter is `to_entries` over the **root object only**, with + `test("account"; "i")` matching a **substring** of the key, and it has **no else branch**. So the + promise holds only for a field that is both top-level and `account`-named; a field nested inside an + object (`user.account_uuid`) or named `user`, `identity`, `org`, or `seat` is dropped, and dropped + silently — in a contract that fail-closes on every other unresolvable input. Both bullets now state + the filter's actual reach (top-level, substring, case-insensitive), name the silent drop, and scope + the no-change claim to that one shape; every other shape is called out as needing a writer change. + The tee-shape bullet owns the statement and the invariant bullet points at it. `statusline-tee.sh` + is unchanged — widening the filter is a design question owned by `TODO(#1218)`, not this + correction. + ## [0.3.4] ### Fixed diff --git a/plugins/rate-limit-guard/reference/reader-contract.md b/plugins/rate-limit-guard/reference/reader-contract.md index c7f653a72..31e5779cb 100644 --- a/plugins/rate-limit-guard/reference/reader-contract.md +++ b/plugins/rate-limit-guard/reference/reader-contract.md @@ -47,9 +47,12 @@ never sees torn JSON; the file is **last-writer-wins** across all sessions on th (, verified 2026-07-23): `used_percentage` is 0–100, `resets_at` is Unix epoch seconds. The key is present **only** when the session observes subscription windows; each window may be independently absent. -- Session-distinguishing fields — `session_id`, `session_name`, and any future top-level field whose - key matches `account` (case-insensitive) are copied through automatically, so the release that - adds an account identifier upgrades this file without a plugin change. Treat these values as +- Session-distinguishing fields — `session_id`, `session_name`, and any **top-level** field whose key + **contains** `account`, case-insensitively, are copied through automatically. That is the whole of + the writer's filter, and it has no else branch: a field nested inside an object + (`user.account_uuid`), or named anything else (`user`, `identity`, `org`, `seat`), is dropped + silently. A future account identifier therefore reaches this file unchanged only if it arrives + top-level and `account`-named; any other shape needs a writer change. Treat these values as **untrusted**: `session_name` (and potentially a future account field) is user/AI-influenced, so consumers parse them only with a JSON parser and never string-interpolate them into a shell command, another interpreter, or a prompt. @@ -104,8 +107,10 @@ is part of the seam only in the sense that tooling sweeping the directory should on the first, and the guard cannot detect it. The loop-lane convention §6 owns the framing and records it as a gap rather than as a safe assumption; the account-identity design that resolves it — writer-side field, reader-side invalidation of latched state, lane-floor re-audit — is - `TODO(#1218)`. Locally relevant today: the writer already forward-passes any top-level key - matching `account`, so an identity field costs no plugin change the release one appears. + `TODO(#1218)`. Locally relevant today, and only this far: the writer already forward-passes a + top-level key containing `account` (see "Tee file shape"), so an identity field of exactly that + shape costs no writer change the release one appears. Any other shape — nested, or named anything + else — is dropped silently and needs a filter change. - **No shipped Monitor config.** Consumers arm their own session Monitor on the tee file (the staleness rule makes this mandatory while paused). The plugin ships no `experimental.monitors` entry — Monitors is an experimental Claude Code component, and this plugin takes no dependency on From 739d06627c3807fd88ab23c74a5df00080982d70 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:36:07 -0400 Subject: [PATCH 2/2] fix(rate-limit-guard): correct the remaining forward-pass claims and the value-scope of a passed key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the first pass fixed the least-read of four surfaces and introduced one error of its own. Two surfaces still carried the unqualified promise: README.md's known-gap bullet ("the wrapper automatically adopts any future account-identifying field the schema grows") and statusline-tee.sh's header comment, whose "so ..." inference did not carry forward the qualifiers its own parenthetical stated. The README is the plugin's front door, and the harm the item names — a planner reading "the writer is already ready" and skipping the filter — routes through it at least as readily as through the reference doc. The first pass also implied that nesting defeats the forward-pass. It does not. Selection is on the top-level key name alone, and a selected key crosses with its whole value, nested objects included; the drop is caused by no top-level key matching, not by depth. Since a passed key may therefore carry an object of arbitrary user/AI-influenced strings, the untrusted-value discipline now covers the subtree rather than a scalar. Also unified on "writer change" (the contract had split between "writer change" and "filter change"), and dropped two phrasings inherited from the item body that do not survive contact with the code: the filter also selects rate_limits, so it is not "the whole of" the forward-pass, and `map(select(...))` has no branch to lack an else. statusline-tee.sh's behavior is unchanged — the jq filter is byte-identical to main and only its header comment moved. Co-Authored-By: Claude Opus 5 (1M context) --- plugins/rate-limit-guard/CHANGELOG.md | 32 +++++++++++-------- plugins/rate-limit-guard/README.md | 5 +-- .../reference/reader-contract.md | 28 ++++++++-------- .../scripts/statusline-tee.sh | 6 ++-- 4 files changed, 40 insertions(+), 31 deletions(-) diff --git a/plugins/rate-limit-guard/CHANGELOG.md b/plugins/rate-limit-guard/CHANGELOG.md index ec373e769..0f07c8901 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -7,20 +7,24 @@ All notable changes to the `rate-limit-guard` plugin are documented here. Format ### Fixed -- **Reader contract: the tee's `account` forward-pass no longer promises a no-change upgrade path it - cannot deliver (#1685).** Two bullets — the tee-shape field list and the single-account gap - invariant — described the writer's filter accurately and then drew a conclusion broader than the - filter supports: that the release adding an account identifier "upgrades this file without a plugin - change" and "costs no plugin change". The filter is `to_entries` over the **root object only**, with - `test("account"; "i")` matching a **substring** of the key, and it has **no else branch**. So the - promise holds only for a field that is both top-level and `account`-named; a field nested inside an - object (`user.account_uuid`) or named `user`, `identity`, `org`, or `seat` is dropped, and dropped - silently — in a contract that fail-closes on every other unresolvable input. Both bullets now state - the filter's actual reach (top-level, substring, case-insensitive), name the silent drop, and scope - the no-change claim to that one shape; every other shape is called out as needing a writer change. - The tee-shape bullet owns the statement and the invariant bullet points at it. `statusline-tee.sh` - is unchanged — widening the filter is a design question owned by `TODO(#1218)`, not this - correction. +- **The tee's `account` forward-pass no longer promises a no-change upgrade path it cannot deliver + (#1685).** Four surfaces claimed that the release adding an account identifier upgrades the tee + file for free — the reader contract's tee-shape bullet and single-account gap invariant, the + README's known-gap bullet ("the wrapper automatically adopts any future account-identifying field + the schema grows"), and the tee script's own header comment. Each described the writer accurately + and then drew a conclusion broader than it supports. The writer selects on the **top-level key + name only** (`to_entries` over the root object), matching `account` as a **case-insensitive + substring**; an unmatched key is dropped with no diagnostic, in a contract that fail-closes on + every other unresolvable input. The promise therefore holds only when the new field's own + top-level key name contains `account`: `user`, `identity`, `org`, `seat`, and an `account_uuid` + buried inside a non-matching object all vanish silently. All four surfaces now scope the claim to + that shape and say every other shape needs a writer change. +- **The reader contract now states that a forward-passed key carries its whole value.** A selected + top-level key crosses complete, nested objects included (`account_info: {uuid, display_name}`), so + the untrusted-value discipline is restated to cover an **object of arbitrary strings** rather than + only a scalar — the parse-with-a-JSON-parser, never-interpolate rule applies to the whole subtree. +- `statusline-tee.sh`'s **behavior is unchanged**; only its header comment was corrected. Widening + the filter is a design question owned by `TODO(#1218)`, not this correction. ## [0.3.4] diff --git a/plugins/rate-limit-guard/README.md b/plugins/rate-limit-guard/README.md index 490852a7a..3a56afd1c 100644 --- a/plugins/rate-limit-guard/README.md +++ b/plugins/rate-limit-guard/README.md @@ -35,8 +35,9 @@ resume on their own after the reset. Four parts: - **Multi-account operation is a known gap, not a supported mode.** The snapshot carries no account identifier (none exists in the statusline schema today), so a machine switching accounts mid-drain feeds wrong windows to running lanes and the guard cannot detect it. The loop-lane convention §6 - owns that framing; the reader contract cites it. The wrapper automatically adopts any future - account-identifying field the schema grows. + owns that framing; the reader contract cites it. The wrapper adopts a future account-identifying + field automatically only when its own top-level key name contains `account`; every other shape + needs a writer change (`reference/reader-contract.md`, "Tee file shape"). ## Install diff --git a/plugins/rate-limit-guard/reference/reader-contract.md b/plugins/rate-limit-guard/reference/reader-contract.md index 31e5779cb..72d3e34e1 100644 --- a/plugins/rate-limit-guard/reference/reader-contract.md +++ b/plugins/rate-limit-guard/reference/reader-contract.md @@ -47,15 +47,18 @@ never sees torn JSON; the file is **last-writer-wins** across all sessions on th (, verified 2026-07-23): `used_percentage` is 0–100, `resets_at` is Unix epoch seconds. The key is present **only** when the session observes subscription windows; each window may be independently absent. -- Session-distinguishing fields — `session_id`, `session_name`, and any **top-level** field whose key - **contains** `account`, case-insensitively, are copied through automatically. That is the whole of - the writer's filter, and it has no else branch: a field nested inside an object - (`user.account_uuid`), or named anything else (`user`, `identity`, `org`, `seat`), is dropped - silently. A future account identifier therefore reaches this file unchanged only if it arrives - top-level and `account`-named; any other shape needs a writer change. Treat these values as - **untrusted**: `session_name` (and potentially a future account field) is user/AI-influenced, so - consumers parse them only with a JSON parser and never string-interpolate them into a shell - command, another interpreter, or a prompt. +- Session-distinguishing fields — `session_id`, `session_name`, and any **top-level** key whose name + **contains** `account` (case-insensitive) are copied through automatically. The writer selects on + the **top-level key name only**, and a selected key carries its **whole value** across, nested + objects included: `account_info: {uuid, display_name}` arrives complete. A key that does not match + is dropped with no diagnostic — `user`, `identity`, `org`, and `seat` all vanish silently, and so + does an `account_uuid` buried inside a non-matching object such as `user`, because nothing at the + top level matched. A future account identifier therefore arrives without a writer change only when + its own top-level key name contains `account`; every other shape needs one. Treat these values as + **untrusted**: `session_name`, and any future account field — which may be an **object of + arbitrary strings**, not just a scalar — are user/AI-influenced, so consumers parse them only with + a JSON parser and never string-interpolate them into a shell command, another interpreter, or a + prompt. ## Capability detection (fail-open) @@ -107,10 +110,9 @@ is part of the seam only in the sense that tooling sweeping the directory should on the first, and the guard cannot detect it. The loop-lane convention §6 owns the framing and records it as a gap rather than as a safe assumption; the account-identity design that resolves it — writer-side field, reader-side invalidation of latched state, lane-floor re-audit — is - `TODO(#1218)`. Locally relevant today, and only this far: the writer already forward-passes a - top-level key containing `account` (see "Tee file shape"), so an identity field of exactly that - shape costs no writer change the release one appears. Any other shape — nested, or named anything - else — is dropped silently and needs a filter change. + `TODO(#1218)`. Locally relevant today, and only this far: the writer already forward-passes an + account-matching key under the exact rule "Tee file shape" states, so an identity field of that + shape costs no writer change the release one appears — and every other shape costs one. - **No shipped Monitor config.** Consumers arm their own session Monitor on the tee file (the staleness rule makes this mandatory while paused). The plugin ships no `experimental.monitors` entry — Monitors is an experimental Claude Code component, and this plugin takes no dependency on diff --git a/plugins/rate-limit-guard/scripts/statusline-tee.sh b/plugins/rate-limit-guard/scripts/statusline-tee.sh index b67197f59..35088feb9 100755 --- a/plugins/rate-limit-guard/scripts/statusline-tee.sh +++ b/plugins/rate-limit-guard/scripts/statusline-tee.sh @@ -22,8 +22,10 @@ # side): every refresh writes ~/.claude/rate-limit-guard/rate-limits.json — # one JSON object with captured_at (ISO-8601 UTC) plus, when present on # stdin, rate_limits and every session-distinguishing top-level field -# (session_id, session_name, and any key matching "account", so a future -# account-identifier field is adopted automatically the release it appears). +# (session_id, session_name, and any key whose name contains "account", so a +# future account-identifier field is adopted automatically only when it +# arrives under a top-level key of that shape; every other shape needs a +# writer change). # The path is deliberately HOME-anchored and outside ${CLAUDE_PLUGIN_DATA}: # it is a documented cross-plugin artifact seam that sibling-plugin lane # sessions read, machine-scope by design (the file is last-writer-wins and