docs(gl): describe the client-contract rules in plain English

The comments carried internal shorthand identifiers that resolve to nothing
for a reader outside this repo. Replace each with the property it names: a
denial must surface as an error rather than render as an empty or fabricated
result, and an error body must be read under a cap and sanitized before it
reaches the terminal. Comment-only; no behavior change.

Verified after the edit because the completeness gate reads source text to
decide which handlers it scans: reverting a converted handler to a bare
.json().await still turns it red. gl: 412 unit tests green, fmt and clippy
clean, cargo check --workspace --locked clean.
This commit is contained in:
beardthelion committed 2026-08-10 22:00:53 -05:00
1 parent d48ae1ea43
commit 21ba974c54
9 files changed
+23 -22

No files matched your search

+1 -1
View File
@@ -576,7 +576,7 @@ mod tests {
#[tokio::test]
async fn cmd_list_repo_scoped_surfaces_denial_not_empty() {
// `gl bounty list --repo owner/name` hits the gated /repos/{o}/{n}/bounties;
// a 404 must Err, not silently print nothing (INV-8).
// a 404 must Err, not silently print nothing.
let mut server = mockito::Server::new_async().await;
let _m = server
.mock(
+6 -6
View File
@@ -206,7 +206,7 @@ async fn obtain_proof(cfg: IcaptchaCfg) -> Result<String> {
/// Read at most `cap` bytes of a response body. Bounds the allocation from a
/// hostile or broken node returning a huge error body — the display is capped
/// separately, but the read itself must not be unbounded (INV-6, read half).
/// separately, but the read itself must not be unbounded either.
pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) -> String {
let mut buf: Vec<u8> = Vec::new();
while buf.len() < cap {
@@ -227,7 +227,7 @@ pub(crate) async fn read_body_capped(mut resp: reqwest::Response, cap: usize) ->
/// Strip terminal-dangerous characters from (and cap the length of) a
/// node-supplied error string before surfacing it. The node a caller talks to
/// could be hostile and embed escape sequences in its error body; those must not
/// reach the terminal verbatim (INV-6). We drop the C0/C1 control bytes (which
/// reach the terminal verbatim. We drop the C0/C1 control bytes (which
/// defangs ANSI/OSC escapes) AND the Unicode bidi/format controls (which
/// `char::is_control` does not cover — they can reorder the displayed line).
pub(crate) fn sanitize_node_msg(s: &str) -> String {
@@ -239,13 +239,13 @@ pub(crate) fn sanitize_node_msg(s: &str) -> String {
/// Read a JSON response, surfacing a node denial/error instead of parsing it as
/// the requested resource. On a non-2xx status it returns an `Err` carrying the
/// node's sanitized `message` (INV-6) plus the status; on success it parses the
/// node's sanitized `message` plus the status; on success it parses the
/// body and propagates a parse error, so a truncated/garbage 2xx body is an error
/// rather than a silently-empty success (the denial-as-success bug #123 fixes).
/// `what` names the resource for the error text (e.g. "repo", "commits").
///
/// Callers must route gated reads through this rather than `resp.json().await?`:
/// the bare parse renders a gated 404/5xx body back as the resource (INV-8).
/// the bare parse renders a gated 404/5xx body back as the resource.
/// Cap on the error body `read_json` reads before parsing a node's `message`.
/// Matches the capped-read bound used on the sync error path; large enough for any
/// legitimate error, small enough that a hostile node cannot exhaust memory.
@@ -707,7 +707,7 @@ mod tests {
assert_eq!(out, "ok \u{0627}\u{200D}b");
}
// ── read_json (status-checked read; #123 / INV-8 / INV-6) ────────────
// ── read_json (status-checked, bounded, sanitized read; #123) ────────
/// Drive a real `reqwest::Response` off a mockito mock so `read_json` sees an
/// actual HTTP status + body, the same shape the gated read arms produce.
@@ -775,7 +775,7 @@ mod tests {
#[tokio::test]
async fn read_json_sanitizes_control_and_bidi_in_message() {
// INV-6: a hostile node embeds ESC, BEL, and a right-to-left override in
// A hostile node embeds ESC, BEL, and a right-to-left override in
// the error message; none may reach the terminal verbatim.
let mut server = Server::new_async().await;
let resp = response_for(
+2 -2
View File
@@ -2139,7 +2139,7 @@ mod tests {
assert_eq!(count, 40, "expected 40 tools, got {count}");
}
// ── Gated read arms surface node denials, not fabricated results (#123 / INV-8) ──
// ── Gated read arms surface node denials, not fabricated results (#123) ──
#[tokio::test]
async fn repo_get_surfaces_denial_not_fabricated_repo() {
@@ -2359,7 +2359,7 @@ mod tests {
_m.assert_async().await;
}
// ── INV-8 across the MCP twin: every tool that renders a node response must
// ── Across the MCP twin: every tool that renders a node response must
// surface a denial as an Err, not print the 4xx/5xx body as a success. ──
/// Drive one MCP tool against a node returning a 404 and assert it Errs
+1 -1
View File
@@ -853,7 +853,7 @@ mod tests {
.unwrap();
}
// ── Gated PR reads surface node denials, not empty/stub renders (#123 / INV-8) ──
// ── Gated PR reads surface node denials, not empty/stub renders (#123) ──
#[tokio::test]
async fn cmd_list_surfaces_denial_not_empty() {
+4 -4
View File
@@ -344,7 +344,7 @@ async fn cmd_info(repo: String, node: String, dir: Option<PathBuf>) -> Result<()
anyhow::bail!("repository '{owner}/{name}' not found");
}
// Every other status routes through read_json: a 2xx yields the parsed body,
// any other non-2xx yields the node's capped + sanitized message (INV-6), and
// any other non-2xx yields the node's capped + sanitized message, and
// the error read is bounded rather than buffering the whole hostile body.
let r = crate::http::read_json(resp, "repo info").await?;
@@ -893,7 +893,7 @@ mod tests {
#[tokio::test]
async fn cmd_list_repos_surfaces_denial() {
// A node error must Err with the status, not trip the vague "expected
// array" fallback that hides the node's actual response (INV-8).
// array" fallback that hides the node's actual response.
let dir = TempDir::new().unwrap();
write_identity(&dir);
@@ -1423,7 +1423,7 @@ mod tests {
_repo.assert_async().await;
}
// ── Gated CLI reads surface node denials, not empty renders (#123 / INV-8) ──
// ── Gated CLI reads surface node denials, not empty renders (#123) ──
#[tokio::test]
async fn cmd_commits_surfaces_denial_not_empty() {
@@ -1717,7 +1717,7 @@ mod vet_merge_deny_arms {
}
// An authenticated caller DENIED with a 403 must surface an error, never
// render a repo card (deny-as-success is the INV-8 client-contract break).
// render a repo card (deny-as-success is the client-contract break).
#[tokio::test]
async fn cmd_info_403_denied_surfaces_error_not_card() {
let dir = TempDir::new().unwrap();
+5 -5
View File
@@ -133,7 +133,7 @@ pub async fn run(args: StatusArgs) -> Result<()> {
}
/// The status line for a repo section (PRs / issues) whose gated read returned a
/// non-2xx: the denial must surface, never render as "no open ..." (INV-8).
/// non-2xx: the denial must surface, never render as "no open ...".
/// Returns `None` for a success (the caller renders the body) or a transport error
/// (the section degrades silently — R5 — so the multi-section `gl status` never
/// hard-fails on one denied read).
@@ -149,7 +149,7 @@ fn section_unavailable_line(label: &str, resp: &Result<reqwest::Response>) -> Op
/// The `trust` status line for the caller's own identity. A genuine 404 means the
/// identity is not registered; any OTHER non-2xx (403/429/5xx) is a failed lookup
/// and must surface as unavailable rather than fabricating an unregistered state
/// (INV-8). A transport error degrades silently — the node-unreachable line below
/// A transport error degrades silently — the node-unreachable line below
/// already surfaces it — matching `section_unavailable_line`.
async fn trust_line(resp: Result<reqwest::Response>) -> Option<String> {
match resp {
@@ -407,7 +407,7 @@ mod tests {
assert_eq!(trust_bar(0.25), "█░░░");
}
// ── gl status section denial surfacing (#123 / INV-8, R5) ────────────
// ── gl status section denial surfacing (#123, R5) ────────────────────
async fn get_response(
server: &mut mockito::Server,
@@ -426,7 +426,7 @@ mod tests {
#[tokio::test]
async fn gated_section_surfaces_unavailable_not_empty() {
// A gated 404 on a status section must surface "unavailable", never be
// treated as "no open PRs" (INV-8).
// treated as "no open PRs".
let mut server = mockito::Server::new_async().await;
let resp = get_response(&mut server, 404).await;
assert_eq!(
@@ -452,7 +452,7 @@ mod tests {
}
// ── trust_line: a 404 means unregistered; any OTHER failure must surface as
// unavailable, never fabricate an unregistered verdict (INV-8). ──────────
// unavailable, never fabricate an unregistered verdict. ───────────────
async fn agents_resp(
server: &mut mockito::Server,
status: usize,
+1 -1
View File
@@ -45,7 +45,7 @@ pub async fn run(args: SyncArgs) -> Result<()> {
let status = resp.status();
if !status.is_success() {
// Bound the read: a hostile or broken node must not force an
// unbounded allocation just to surface a denial (INV-6, read half).
// unbounded allocation just to surface a denial.
let raw = read_body_capped(resp, 8 * 1024).await;
let msg = serde_json::from_str::<serde_json::Value>(&raw)
.ok()
+1 -1
View File
@@ -613,7 +613,7 @@ mod tests {
.unwrap();
}
// ── denial surfaces (INV-8): a node 4xx/5xx must Err, not render as success ──
// ── denial surfaces: a node 4xx/5xx must Err, not render as success ──
#[tokio::test]
async fn test_list_tasks_surfaces_denial() {
+2 -1
View File
@@ -1,4 +1,5 @@
//! INV-21 completeness gate for #186.
//! Completeness gate for #186, itself proven load-bearing by reverting a
//! converted handler and watching this go red.
//!
//! The client handlers converted in #186 must read node responses through
//! `crate::http::read_json` — status-first, capped error read, sanitized message.