From 2aaf4ffb8d10444836fb114d1ab4c9322a725e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:39:07 +0200 Subject: [PATCH 1/2] docs(architecture): a refusal can BE the positive signal, and creation is a second limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two shapes measured today that ReadingCiSignals did not carry, both in the family it already documents (an answer that reads like a pass) and both caught only because someone looked past the signal at the consequence. A watcher polled a file's sha with `--jq '.sha' || true` and compared it to a baseline. Under a secondary rate limit the jq yielded the 403 BODY, `!=` against the 40-hex baseline was true, and it printed "ACT NOW … MOVED" with a remedy. Nothing had moved. This is the inverted twin of the watcher that reports "not yet" forever, and strictly worse: it manufactures an action instead of withholding one. Two correct decisions compose into it — `|| true` so one refusal cannot kill a long watch, and `!=` as the change test — and together they mean any failed read is a positive result. The guard is to validate the value's SHAPE before comparing, make "could not read" a third printed outcome, and dry-run the watcher against a forced failure. Separately, the page's primary/secondary table concerns reads, and there is a SECOND secondary limit on content creation that is reached independently. `gh issue create` exited 0 with both streams empty and created nothing; the same request over REST named the 403. The consequence that matters is a lost review-thread reply, which leaves the gate red with no signal, so every creation is read back — keyed on something only the NEW content has, because an existence check passes on a stub and on any earlier session's reply. Verified: MeshWeaver.Documentation.Test 601 succeeded, 0 failed, 0 skipped; the project builds Release -warnaserror with 0 Warning(s), 0 Error(s). No internal links added, so nothing new for DocumentationLinkIntegrityTest to resolve. No What's New entry: measured over the last 25 commits touching Data/Architecture, none of the doc-only ones carries one. Co-Authored-By: Claude Opus 5 (1M context) --- .../Data/Architecture/ReadingCiSignals.md | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md b/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md index c5be2b5229..dc1ccdaa1b 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md @@ -284,6 +284,82 @@ Two bugs that make a monitor lie, both hit in one session: takes the first line. The failure grows with the repository's check volume, so a reader that works today starts lying later, silently. +### 🚨 A comparison against an UNVALIDATED read turns a refusal into "ACT NOW" + +The watcher bugs above withhold an action. This one **manufactures** one, which makes it strictly +worse: it arrives wearing urgency and a ready-made remedy. + +A watcher polled a file's sha and compared it to a baseline: + +```bash +gh api "repos/.../contents/scripts/resolve-platform.py?ref=main" --jq '.sha' || true +``` + +Measured 2026-09-19 09:08:35Z, under a secondary rate limit, `--jq '.sha'` yielded the **refusal +body**, `!=` against the 40-hex baseline was therefore true, and the watcher announced: + +``` +ACT NOW: main's resolve-platform.py MOVED (3361378ce5e2… -> {"message":"API rate limit exceeded +for user ID …","status":"403"}) — merge origin/main into and push +``` + +Nothing had moved; the file was byte-for-byte unchanged, confirmed locally with no network. Acting on +it would have merged `main` without the awaited fix and spent a CI run during the limit. + +Two individually-correct decisions compose into it. `|| true`, so one transient refusal cannot kill a +long watch — right. `!=` against the previous value as the change test — right. Together they mean +**any failed read is a positive result.** + +**The question to ask before arming any watcher: what does this print on a 403? If that is its success +branch, it is not a watcher.** Three guards, and the first is the one that matters: + +- **Validate the value's SHAPE before comparing it.** A sha must match `^[0-9a-f]{40}$`, an md5 + `^[0-9a-f]{32}$`, a count must be all digits. Anything else is not a value. +- **Make "could not read" its own printed outcome**, distinct from both *changed* and *unchanged*, and + back off after it. Three states, never two. +- **Dry-run it against a forced failure** — unset the token, or point it at a 404 path — and read what + it says. A watcher whose failure branch has never been exercised is a guess. + +The same hole is easy to leave in an equality test rather than an inequality one: a monitor comparing +two digests and announcing agreement on equality would, under a total refusal, digest two error bodies +and declare them identical. On this occasion they differed only because each 403 carries a distinct +request id. That is luck, not a design. + +### 🚨 The CREATION limit is a second secondary limit, and `gh` porcelain exits 0 under it + +The primary/secondary table above concerns reads. There is a **separate** secondary limit on content +creation — issues, pull requests, comments, and review-thread replies — and it is reached +independently. Measured 2026-09-19 08:51:44Z: + +``` +$ gh issue create --repo … --title … --body-file … > out.txt 2> err.txt +exit=0 out.txt: empty err.txt: empty issue: DOES NOT EXIST +``` + +**Exit 0, both streams empty, nothing created.** The same request over REST named it at once: + +``` +$ gh api --method POST repos/…/issues --input payload.json +{"message":"You have exceeded a secondary rate limit and have been temporarily blocked from +content creation. …","status":"403"} +``` + +So create over REST, and build the JSON with `python3 json.dumps` rather than interpolating a body +into a shell string. Two consequences worth stating plainly: + +- **A lost review-thread reply is invisible in exactly the way that matters.** The review gate stays + red, `mergeable_state` stays `blocked`, and the agent that "replied" has no signal it did not. Then + re-running the gate looks like the gate is broken when the thread is genuinely unanswered. +- **The two limits are not ordered.** Creation was refused at 08:51Z while reads still worked; reads + were refused at 09:11Z with `/rate_limit` reporting `core: 5000/5000`. Neither one predicts the + other, and the read meter reports neither. + +**Verify every creation by reading it back, keyed on something only the NEW content has.** An +existence check such as `select(.in_reply_to_id == )` passes on a pre-existing reply and on a stub, +and a retrier keyed that way reported success while a wrong reply sat there untouched. Byte-compare the +fetched body against the file that was posted, or at minimum assert its exact length. An existence +check is sound only against a baseline measured *before* the write. + ### 🚨 A lookup that cannot reach its target answers the DEFAULT, forever, on every machine The other direction of the same defect: the gate runs, its input is a constant, and the constant is From 274105d567ec5ad6ffb9ecc94f06126d552c2645 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20B=C3=BCrgi?= <6334612+rbuergi@users.noreply.github.com> Date: Sat, 19 Sep 2026 11:48:05 +0200 Subject: [PATCH 2/2] review(#4790): a length is not an identity proof, so separate the two questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Copilot's finding is right and it is the sharper form of the point the section was making. The text offered "byte-compare the fetched body … or at minimum assert its exact length", which presents a weak check as a sufficient one: a stub, or an earlier session's reply, matches a length as easily as it matches an `in_reply_to_id`. Two questions were collapsed into one sentence, and they have different answers. Whether THIS write created a comment is answered only by the `id` in the write's own REST response, re-fetched by that id; nothing derived from the body can answer it. Whether that comment carries the intended content is answered by a byte-compare against the file posted. Length answers neither, so it is removed rather than demoted. The existing point about a pre-write baseline is kept and scoped to the case where no response id exists — auditing somebody else's earlier posts. The scripts behind this section already did the strong thing while the prose described the weak one: they took `number`/`id` from the POST response and re-fetched by it. The guidance was weaker than the practice, which is the easier direction for this drift to run. Demonstrated rather than asserted: the reply to this finding was posted, re-fetched by the id the write returned, and byte-compared against its source file before this push. Verified: MeshWeaver.Documentation.Test 601 succeeded, 0 failed, 0 skipped. Co-Authored-By: Claude Opus 5 (1M context) --- .../Data/Architecture/ReadingCiSignals.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md b/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md index dc1ccdaa1b..311dff2fda 100644 --- a/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md +++ b/src/MeshWeaver.Documentation/Data/Architecture/ReadingCiSignals.md @@ -354,11 +354,19 @@ into a shell string. Two consequences worth stating plainly: were refused at 09:11Z with `/rate_limit` reporting `core: 5000/5000`. Neither one predicts the other, and the read meter reports neither. -**Verify every creation by reading it back, keyed on something only the NEW content has.** An -existence check such as `select(.in_reply_to_id == )` passes on a pre-existing reply and on a stub, -and a retrier keyed that way reported success while a wrong reply sat there untouched. Byte-compare the -fetched body against the file that was posted, or at minimum assert its exact length. An existence -check is sound only against a baseline measured *before* the write. +**Verify every creation by reading it back — and note that this is TWO questions, not one.** + +- **Did THIS write create a comment?** Only the `id` (or `number`) in the write's own REST response + answers it, re-fetched by that id. Nothing derived from the body can: a stub, or an earlier session's + reply, matches a length as easily as it matches an `in_reply_to_id`. +- **Does that comment carry the content intended?** A byte-compare of the fetched body against the file + that was posted. + +So an existence predicate such as `select(.in_reply_to_id == )` proves neither — it passes on a +stub, and a retrier keyed that way reported success while a wrong reply sat there untouched. A length +check proves neither either. Where no response id is available, for instance when auditing somebody +else's earlier posts, an existence check is sound **only** against a baseline measured *before* the +write; that baseline is what makes it proof, not the read-back. ### 🚨 A lookup that cannot reach its target answers the DEFAULT, forever, on every machine