Skip to content

SPEC §9: peer-derived text in observer-facing errors (boundary unresolved) - #802

Open
jeremy wants to merge 1 commit into
mainfrom
wt/lane-spec-peer-text
Open

SPEC §9: peer-derived text in observer-facing errors (boundary unresolved)#802
jeremy wants to merge 1 commit into
mainfrom
wt/lane-spec-peer-text

Conversation

@jeremy

@jeremy jeremy commented Aug 20, 2026

Copy link
Copy Markdown
Member

Not ready for review. Opened to hold verified work and to record why its central boundary is still open. Split out of #793 so that PR's settled §6 material could ship without waiting on this.

Why this is separate

§6 was decided: a status rule, a composition table walked back across 11 branches, a definition of "retry", the representability tiers, zero open findings. §9 moved its central boundary three times in one review cycle, and the fourth candidate's premise failed a spot check before it was written. That is a section still being designed, not one being reviewed.

The boundary, and why three formulations failed

Formulation How it failed
A list of call sites A sweep of every network-error construction found ~30 across six SDKs. Three rounds each added correct rows and the list was still incomplete after every one.
"Every construction of the network-error type" Two paths never construct one: hook dispatch hands the raw error to on_request_end before wrapping (typescript/src/services/base.ts, kotlin/.../BasecampHttpClient.kt), and generated Go's non-idempotent branch returns c.Client.Do(req) directly, bypassing doWithRetry entirely.
Egress — "exactly five ways a value leaves" A sweep defeated it with three members fitting none of the five: BasecampError.toJSON() serialising message and hint; typescript/src/hooks/otel.ts and go/pkg/basecamp/otel/otel.go writing err.Error() into span status, recordException and attributes; Kotlin's BasecampHooks println.

The common defect is that all three enumerate destinations, and destinations are an open set — they grow with every integration anyone writes (a new tracing backend, a to_dict() for structured logging, a Sentry hook). Renaming "call sites" to "egresses" changed the granularity, not the openness.

Current best candidate, with its premise unverified

Sanitize at ingress — the point where the transport hands the SDK a failure — so no raw peer-bearing value ever circulates and no sink needs enumerating. Every failed member above dissolves without being named, including otel.ts, toJSON, and integrations not yet written.

The premise is not verified and is already known to be weaker than first stated. "One transport module per SDK" does not hold: a spot check found ~5–7 ingress points in TypeScript, because the five oauth/* modules and download.ts are hand-written outside the generated transport by AGENTS.md's own design. Ingress is still a better shape than egress — it is bounded by the SDK's own architecture rather than by what anyone does with a value — but the closure argument has to be made honestly this time, and it has not been made yet.

This is marked [CONFLICT] in the section itself, so nobody reads the closure claim as load-bearing.

What is carried here and stands on its own

  • The scope clause and its three excluded contracts — §6's error-body message and the field-keyed composition over it, §6's statusless api_error for a malformed 2xx body (whose entire diagnostic is the wire value it embeds), and §23's origin-only rendering. Each requires specific text to reach the caller, so the truncation cap governs and this section makes no demand.
  • Rule 3's cancellation-classification requirement. Verified in code: Go's shouldTripCircuit reaches context.Canceled and context.DeadlineExceeded only through ErrNetwork's Cause, and Swift's isCancellation walks .network causes. A naive "drop the cause" would trip the circuit breaker on a cancelled or timed-out request and retry a cancelled one. Projection onto a freshly constructed peer-free sentinel preserves errors.Is and isCancellation while carrying no peer bytes.
  • The parse-failure fallback token. §8's same-origin check rejects a URL that does not parse — the input most in need of rejecting is the one with no origin to project — and §23 still requires an origin in the error. Fixed token unparsable, selected by the parse outcome.
  • Python's __context__ obligation, which survives whichever boundary wins, since CPython attaches the active exception at raise time, after any constructor has returned. Verified in a REPL, including the trap that clearing __context__ before the raise is silently overwritten — a fix that passes a unit test on the constructed object and still leaks in production traceback output.

Leak sites found and recorded

typescript/src/hooks/otel.ts (4 renderings), go/pkg/basecamp/otel/otel.go (5), typescript/src/errors.ts toJSON(), kotlin/.../BasecampHooks.kt (2) — all on #788 with negative results from the same sweep (metric labels are clean, using error.name; no serialization member outside TypeScript).

Refs #788.


Summary by cubic

Codifies §9 rules for rendering peer-derived text: observer-facing errors must use a closed vocabulary keyed by error type instead of composing peer input, and recognized causes are projected to peer-free sentinels to preserve cancellation classification. Adds the fixed origin token unparsable when a URL cannot parse, clarifies Python’s __context__ raising boundary, and updates §23 and Security notes accordingly. The sanitize boundary (ingress vs egress) remains unresolved and is marked in-spec as a conflict; this PR lands the settled parts.

  • Review focus

    • New §9 scope line: excludes §6 error-body message/fields, §6 malformed-2xx api_error, and §23 origin-only rendering (these remain governed by the truncation cap).
    • Closed-vocabulary rule, four-part rendering/retention guidance, and cause projection requirement that preserves cancellation classification in Go and Swift.
    • Parse-failure fallback: origin renders as unparsable in §23 references; Security section clarifies projection vs redaction.
    • Python-specific note: raising boundary for __context__ to prevent runtime-attached peer exceptions from leaking; constructor-only fixes are insufficient.
    • Conflict block documents why the sanitize boundary is still open; nothing in that block is load-bearing for this PR.
  • Required SDK changes (follow-up)

    • Render observer-facing error text from a fixed set by error type; do not interpolate transport/decoder messages. Retain peer values only as typed fields intentionally accessed by callers.
    • Before discarding a peer-bearing cause, project recognized cancellation sentinels (e.g., Go context.Canceled/DeadlineExceeded, Swift CancellationError/URLError(.cancelled)) to new peer-free instances and chain them; do not chain unrecognized causes.
    • Use unparsable as the origin component when URL parsing fails; continue origin-only projection for parsed URLs.
    • Python: raise outside active handlers or clear __context__ after the raise; from None alone is not sufficient.
    • Audit constructors, logging, hook callbacks, and returns/throws to ensure no raw transport errors egress (notably generated Go’s non-idempotent branch and any sites logging err.Error() directly).

Written for commit a91dd69. Summary will update on new commits.

Review in cubic

…lved)

Split out of #793 so §6's settled Retry-After work can ship on its own. This
half is not ready: its central boundary has moved three times in one review
cycle and the fourth candidate's premise has already failed a spot check.

What is settled here and stands on its own:

- The scope clause, and the three contracts that sit outside it - §6's error
  body message and the field-keyed composition over it, §6's statusless
  api_error for a malformed 2xx body whose entire diagnostic is the wire value
  it embeds, and §23's origin-only rendering. Each requires specific text to
  reach the caller, so the truncation cap governs and this section does not.
- Rule 3's cancellation-classification requirement. Go's shouldTripCircuit
  reaches context.Canceled and context.DeadlineExceeded only through
  ErrNetwork's Cause, and Swift's isCancellation walks .network causes, so a
  naive "drop the cause" would trip the breaker on a cancelled request and
  retry a cancelled one respectively. Projection onto a freshly constructed
  peer-free sentinel preserves both.
- The parse-failure fallback token. §8's same-origin check rejects a URL that
  does not parse, and §23 still wants an origin in the error, so the origin
  component is the fixed token `unparsable`.
- Python's __context__ obligation, which survives whichever boundary wins:
  CPython attaches the active exception at RAISE time, after any constructor
  has returned, so the remedy is a raising boundary rather than a constructing
  one. Clearing __context__ before the raise is silently overwritten.

What is not settled is marked CONFLICT in the section itself, with all three
failed formulations, why each failed, and the unverified premise of the
ingress candidate - so nobody reads the closure claim as load-bearing.
@jeremy

jeremy commented Aug 20, 2026

Copy link
Copy Markdown
Member Author

Two review findings arrived on #793 against the pre-split head, both against SPEC §9 text that now lives here. Reproducing them so they stay next to the section they are about. Both look correct to me on reading; neither is fixed yet, since this PR is not ready and its central boundary is still open.

1. error_descriptionhint is not covered by the scope clause (Codex, PRRT_kwDORBIBeM6atPuH).

When an API error body contains error_description, §6 step 3 requires the peer-supplied value to become hint, and some SDKs include that hint in the default error rendering (for example, Swift's BasecampError.errorDescription). This supposedly exhaustive exception list names only the parsed message, while the next paragraph requires an exception to state why the text is needed; therefore the new rule simultaneously requires implementations to surface and to suppress the same text.

My read: correct, and it is the same shape as Ruby's hint row already in the table — except that here §6 requires the value to be surfaced, which makes it a genuine contract collision rather than a leak. The scope clause lists three excluded contracts and error_descriptionhint is a fourth it does not name. Whichever boundary wins, that clause needs the fourth entry, or §6 step 3 needs to say the hint is not observer-facing by default.

2. The "binds at construction" sentence is stale (Codex, PRRT_kwDORBIBeM6atPuO).

This still says the rule binds at construction, but the newly added correction explicitly moves the boundary to egress because raw errors can reach hooks, direct returns, and logs without any SDK error constructor running. Following this sentence as the normative boundary therefore leaves exactly those newly identified paths unsanitized.

My read: correct. "The rule binds at construction" survived from the second formulation and now contradicts the third. It is a live example of why this section is not ready — two formulations are simultaneously present in the text. It is covered by the [CONFLICT] note at the head of the boundary, but the sentence should go or be demoted whenever the boundary is finally settled.

Both are listed here rather than patched, because patching either now would be a fifth move on a boundary that has already moved three times.

@jeremy
jeremy marked this pull request as ready for review August 22, 2026 06:02
Copilot AI balanced review requested due to automatic review settings August 22, 2026 06:02

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Defines SPEC §9’s handling of peer-derived text in observer-facing errors, while documenting an unresolved sanitization boundary.

Changes:

  • Introduces closed-vocabulary rendering and cause projection rules.
  • Adds Python exception-context requirements.
  • Defines unparsable fallback behavior for event-feed URLs.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread SPEC.md
- **A generated method returns the transport error directly.** In `go/templates/client.tmpl`, only the `$isIdempotent` branch calls `doWithRetry`; the else-branch ends `return c.Client.Do(req)`, so every **non-idempotent** generated operation hands the caller a raw `*url.Error` that no constructor ever sees.
- **A log line takes it without an error object at all** — generated Go's `c.Logger.Debug("request failed", …, "error", err)`.

So the contract is stated over what the SDK *does with the value*, which is a closed set, rather than over where the value was made, which is not: **a peer-bearing transport error must be projected before it egresses, and there are exactly five egresses** — returned or thrown to the caller, passed to a hook or observer callback, written to a log, left in the generic unwrap chain (rule 3), or attached by the runtime as exception context (Python; below). A path that does none of those five may hold the raw value freely.
Comment thread SPEC.md

**Where no contract requires the text to reach the caller, peer-derived text in an observer-facing error is rendered from a closed vocabulary keyed on the error's type. It is never composed from peer input and then bounded by length.**

The leading clause is the scope, not a hedge: it is the boundary the previous section just drew, carried into the sentence that binds so the sentence cannot override it. Three contracts already sit on the other side and are untouched — §6's Error Body Parsing Algorithm `message` and the field-keyed composition over it; §6's statusless `api_error` for a malformed 2xx body, whose entire diagnostic *is* the malformed wire value it embeds; and §23's origin-only rendering of a refused redirect `Location` or a rejected continuation URL. Each requires specific text to reach the caller, so each is governed by the truncation cap above and this section makes no demand of it. What is left is the class four rounds on #788 were actually about — a decoder's rendering of bad bytes, a transport library's rendering of a URL it failed on, a close reason nobody asked to see — and there no contract stands behind the text, so this section governs.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a91dd69d01

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread SPEC.md
- **A generated method returns the transport error directly.** In `go/templates/client.tmpl`, only the `$isIdempotent` branch calls `doWithRetry`; the else-branch ends `return c.Client.Do(req)`, so every **non-idempotent** generated operation hands the caller a raw `*url.Error` that no constructor ever sees.
- **A log line takes it without an error object at all** — generated Go's `c.Logger.Debug("request failed", …, "error", err)`.

So the contract is stated over what the SDK *does with the value*, which is a closed set, rather than over where the value was made, which is not: **a peer-bearing transport error must be projected before it egresses, and there are exactly five egresses** — returned or thrown to the caller, passed to a hook or observer callback, written to a log, left in the generic unwrap chain (rule 3), or attached by the runtime as exception context (Python; below). A path that does none of those five may hold the raw value freely.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the known-false exhaustive egress boundary

When implementers use this normative “exactly five egresses” list to audit the SDKs, peer-derived text can still escape through derived renderings that do not carry the original transport error across one of these boundaries—for example, BasecampError.toJSON() serializes message and hint (typescript/src/errors.ts:165-175), while the OpenTelemetry hooks export error.message (typescript/src/hooks/otel.ts:261-264). The preceding conflict block explicitly acknowledges that these examples defeat the list, so retaining the known-false closure claim leaves this security requirement incomplete and can cause an implementation to be declared converged while it still leaks peer text.

Useful? React with 👍 / 👎.

Comment thread SPEC.md

**Routing every construction through one sanctioned constructor is the recommended mechanism, not the contract.** It covers the common path cheaply and remains the right first move in each SDK; it simply does not discharge the obligation on its own, and an SDK is not converged when its constructors are clean. The test is whether a peer-bearing value can reach any of the five, not whether the type has one construction site.

**Python owes a second boundary, because in Python the constructor cannot discharge this alone.** What follows is an obligation on **Python only** — it falls out of a CPython runtime behaviour with no equivalent in the other five, which inherit nothing from it and owe nothing extra on its account.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Apply the raising-boundary rule to Ruby too

When Ruby raises a sanitized SDK exception from inside a rescue, the runtime also attaches the active exception as its implicit cause at raise time, so constructor-only projection does not make this a Python-only obligation. The repository already accounts for this in ruby/lib/basecamp/oauth/exchange.rb:206-217, using raise ..., cause: nil specifically because the implicit chain would expose peer input through full_message; without the same raising-boundary treatment, Ruby network paths can sanitize their custom cause field while traceback or cause-aware telemetry still emits the raw Faraday error and URL. Declaring that the other five SDKs owe nothing extra also conflicts with the later recommendation to fix Ruby solely in network_error.rb.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants