Skip to content

Final review fixes: guard the logs resolution, narrow the swallow, settle the surface tiers - #11

Merged
pedro-pscunha merged 8 commits into
mainfrom
final-fixes
Sep 21, 2026
Merged

pedro-pscunha merged 8 commits into
mainfrom
final-fixes

Conversation

@pedro-pscunha

Copy link
Copy Markdown
Owner

The final pre-publish review. Eleven findings, all reproduced against the code before
anything changed, all applied, none rejected. Seven commits, each one finding or one
group of them.

The logs signal could go quiet, twice

AGENTS.md and the permanent 0.1.0 changelog entry both promise that a release moving the
private opentelemetry._logs costs the logs signal and nothing else: import guideme
still works and traces are untouched. Two things made that false.

The guard was narrower than the promise. resolve_logs caught ImportError around the
deferred import, then read SeverityNumber.INFO, SeverityNumber.WARN and called
get_logger twice outside it, at module scope through LOGS = resolve_logs(). Against a
module that imports cleanly and has lost a member:

import guideme FAILED: AttributeError: type object '...' has no attribute 'INFO'

Every read of that API now happens inside the one guard, which catches ImportError,
AttributeError and TypeError. The same probe after:

import guideme OK; guideme.telemetry.LOGS is None
events("log") refused: ConfigError: ... needs the OpenTelemetry logs API, and
opentelemetry-api 1.44.0 has none at opentelemetry._logs. ...

The swallow was wider than its docstring. Logs._emit wrapped the private LogRecord
constructor and logger.emit in one except Exception: pass, while the docstring scoped
it to a sink that raises. With a LogRecord that no longer takes event_name, an
events("log") ask produced zero span events, zero log records and no error — the "records
that silently go nowhere" the refusal in GuideBuilder.events exists to prevent:

events("log") accepted
span events: []
log records: 0
nothing raised

resolve_logs now builds one throwaway record with the five keywords _emit uses, so a
constructor change is found where absence is already representable, and _emit builds its
record before the try. The same drift after:

resolve_logs() -> False
events("log") refused LOUDLY: ConfigError: ...

The probe still succeeds against the installed 1.44.0, so today's behaviour is unchanged and
the tracing suite still asserts on real exported log records.

The documents disagreed about guideme.question

docs/design.md told callers to import Question from guideme.question; AGENTS.md and
the README said the supported surface was __all__ plus guideme.api and guideme.policy,
everything else private. The recorded reason for keeping Question out — that no public
signature returns it — was false: noul, choose, choose_among, score, score_levels
and all three .detail() methods return Question subclasses, and tests/test_live.py
imports it from that module.

Settled by naming guideme.question in the second tier, not by growing __all__. The tier
already carries the same major-bump promise, so the contradiction closes without touching the
33-name export list, and the README now documents Question where a caller meets it, with
the dict invariance reason an annotation actually needs it for.

The rest

ask's docstring listed "two runtime keys collide" under its ConfigError. It never raises it: choose_among does, at question-construction time. Clause removed, choose_among says it from the other side.
CHANGELOG.md said ApiKey "has no __str__". It has one, returning ApiKey(***). That entry ships in the sdist and is permanent once uploaded.
docs/observability.md called opentelemetry._logs "the public logs API", the opposite of AGENTS.md and of the premise the deferred import rests on.
ConfigError docstring named one events(...) trigger, not the other: an installed opentelemetry-api with no logs API.
ci.yml the lock-comparison program left its heredoc for scripts/resolved_vs_lock.py, so ruff, pyright strict and pylint check it. Behaviour proven unchanged both ways: unchanged it prints "nothing in the lock has a newer release today"; with a stale certifi substituted into the text it reads out of git, "1 resolved newer than the lock: certifi".
cross-origin redirect api/client.py rests on httpx dropping the authorization header when a redirect changes origin, and only the same-origin case was tested. A second HTTPServer on its own port now makes the hop cross origins. The comment names httpx's carve-out: a direct same-host http → https upgrade keeps the header deliberately.
invalid events(...) three documents promised the refusal and nothing tested it.
release.yml has never executed, and the tag that starts it is immutable, so a first-run defect burns v0.1.0. workflow_dispatch now runs build — the whole gate plus uv build — and publish is gated on github.event_name == 'push', a fact about how the run started rather than an input, a branch or a variable anyone can set. No dispatched run can reach PyPI.

Tests

The ceiling is 43 test functions and the suite was at it. It still is: two more
configuration-mistake parameters and a third redirect arrangement, no new function.
162 passed, 2 deselected, up from 158.

Both new configuration cases were mutation-proved against the unfixed code: with the probe
removed from resolve_logs the drift case fails on telemetry.LOGS is None; with the
EVENT_MODES check removed the unknown-mode case fails DID NOT RAISE ConfigError.

Readings recorded under ## Apply (final) in the assumptions file, including the explicit
correction of the false rationale in ## Apply (docs).

🤖 Generated with Claude Code

AGENTS.md and the 0.1.0 changelog entry both promise that a release moving
opentelemetry._logs costs the logs signal and nothing else: import guideme
still works and traces are untouched. The guard was narrower than that
promise. It covered the deferred import; SeverityNumber.INFO, SeverityNumber
.WARN and both get_logger calls sat outside it, at module scope through
LOGS = resolve_logs(). A module that still imports and has lost a member
therefore killed import guideme with an AttributeError, traces included.

Logs._emit had the matching hole. It wrapped the private LogRecord
constructor and logger.emit in one except Exception: pass, while its
docstring scoped the swallow to a sink that raises. A LogRecord that no
longer takes event_name then produced no record, no span event under
events("log"), and no error: the records that silently go nowhere the
refusal in GuideBuilder.events exists to prevent.

Every read of the private API now happens inside the one guard, which
catches ImportError, AttributeError and TypeError, and resolve_logs builds
one throwaway record with the five keywords _emit uses, so a constructor
change is found where absence is already representable. _emit builds its
record before the try, leaving the swallow over logger.emit alone.
Three gaps, all filled as parameters of tests that already existed, so the
43-function ceiling holds.

A LogRecord whose constructor moved is the drift ImportError cannot see, and
it is what used to make an ask emit nothing and say nothing. The new case
replaces LogRecord on the module, because resolve_logs reads the name off it
every call, and asserts the signal goes absent so events("log") refuses.

guide.py refuses an events(...) value outside span, log and both, and three
documents promise it, but nothing tested it. Events is a Literal, so the
case casts to reach the runtime guard, which is where a caller with no type
checker stands.

api/client.py rests on httpx dropping the authorization header when a
redirect changes origin, and only the same-origin case was covered. A second
HTTPServer on its own port makes the hop cross origins, and the case asserts
the key reaches none of the second server's headers.
ask's docstring listed "two runtime keys collide" under the ConfigError it
raises. It does not raise it: validate(ChoiceSpec) refuses a repeated key
while choose_among builds the question, long before anything is asked. A
caller reading the generated overloads would look for the failure in the
wrong place.

The clause leaves ASK_DOC, which now says a rubric this call could not ask
is refused where the question is built, and choose_among says the same from
the other side.
The class docstring listed an events(...) value outside the three modes and
stopped there. events("log") and events("both") also raise it where the
installed opentelemetry-api provides no logs API, which is the case a reader
hitting it is least likely to guess. The list is open-ended and keeps that
shape.
Four documents disagreed with the code or with each other.

design.md told callers to import Question from guideme.question while
AGENTS.md and the README said the supported surface was __all__ plus
guideme.api and guideme.policy, and everything else private. Question is
what noul, choose, choose_among, score, score_levels and all three
.detail() methods return, and a dict annotation cannot be written without
it, so the module joins the second tier rather than the type joining
__all__: the tier already carries the same major-bump promise, and the
33-name export list stays as the goal fixed it.

The changelog said ApiKey has no __str__. scalars.py defines one returning
ApiKey(***). That entry ships inside the sdist and is permanent once
uploaded, so it now says both repr and str redact.

observability.md called opentelemetry._logs the public logs API, which is
the opposite of AGENTS.md and of the premise the deferred import rests on.
It now says what is useful: no public alias, so a release inside the
accepted range may move it with no notice.

AGENTS.md and the changelog also record what the guard and the swallow now
cover, which the preceding commits changed.
The program that proves latest-deps re-resolved lived in a heredoc inside
ci.yml, where ruff, pyright strict and pylint never saw it. A defect in it
would only ever surface as a job failing in CI, and the job it belongs to is
the one that is deliberately not a required check.

It moves to scripts/resolved_vs_lock.py unchanged in behaviour: it reads the
committed lock out of git, because uv sync --upgrade has already rewritten
the copy on disk, and it counts the packages the lock carries for another
platform rather than looking them up. Strict typing cost two casts, both
carrying the comment that proves them.
release.yml has never executed, and the only event that starts it puts a tag
on the repository that the ruleset refuses to move or delete. A defect in its
first run would burn v0.1.0, and a published version can be yanked but never
replaced.

workflow_dispatch now runs build, which is the whole gate plus uv build, and
stops. publish is gated on github.event_name == 'push', a fact about how the
run started rather than an input, a branch or a variable anyone can set, so
no dispatched run can reach PyPI. The tag-versus-pyproject check is skipped
on a dispatch, where GITHUB_REF_NAME is a branch and there is nothing to
publish.
The documentation review found the last two pull requests had moved
behaviour without moving its record. The events mode lives on the client
now, not on _Config. A ConfigError is raised before the ask span opens,
so config can never be an error.type on it, and an alert keyed on that
would never fire. The logs signal can be refused or degrade when the
installed opentelemetry-api has no logs API, which was stated only in the
contributor guide and one docstring, never where a user meets it.

Two sentences in the changelog were false and the changelog ships inside
the sdist: ApiKey does define __str__, and four of the five scalars are
NewType brands the wire validates rather than validated scalars.

The second tier now promises one name from guideme.question rather than
the module. Naming the module would publish validate, Spec and the
concrete question classes to let a caller annotate one type.

Tightening the surface test to look for each exported name inside code
rather than anywhere in the prose immediately found ModelInfo and
fallback documented only in passing, which is what it is for.
@pedro-pscunha
pedro-pscunha merged commit 15b4425 into main Sep 21, 2026
7 checks passed
@pedro-pscunha
pedro-pscunha deleted the final-fixes branch September 21, 2026 23:42
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.

1 participant