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..0f07c8901 100644 --- a/plugins/rate-limit-guard/CHANGELOG.md +++ b/plugins/rate-limit-guard/CHANGELOG.md @@ -3,6 +3,29 @@ 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 + +- **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] ### Fixed 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 c7f653a72..72d3e34e1 100644 --- a/plugins/rate-limit-guard/reference/reader-contract.md +++ b/plugins/rate-limit-guard/reference/reader-contract.md @@ -47,12 +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 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 - **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) @@ -104,8 +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: 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 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