Skip to content

Obtain a telemetry-only collector token for a build elsewhere, and use one the emitter is given - #34

Merged
anth-volk merged 36 commits into
mainfrom
claude/modal-telemetry-token
Oct 29, 2026
Merged

anth-volk merged 36 commits into
mainfrom
claude/modal-telemetry-token

Conversation

@anth-volk

@anth-volk anth-volk commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #33

Summary

A Microcosm build on Modal has no Hugging Face credential, so its emitter could never authenticate to the collector. This PR is the emitter's half of the accepted design: the operator's workstation obtains a telemetry-only collector token at launch, and the emitter in the container uses it.

  1. microcosm-emitter telemetry-token --lifetime-seconds N (on the workstation). It exchanges this machine's Hugging Face credential at the emitter's own resolved collector origin, with the body {"scope": "telemetry:write", "lifetime_seconds": N}. It prints exactly one JSON object on standard output, {"access_token", "expires_at", "collector_origin"}, and nothing else. expires_at is an ISO 8601 UTC time counted from before the request, so it never overstates the token's real expiry. It prints the token only on standard output and never prints the Hugging Face credential; an unexpected error exits 7 and prints only the error's type.
  2. A given token replaces the Hugging Face exchange. With MICROCOSM_COLLECTOR_TOKEN, MICROCOSM_COLLECTOR_TOKEN_EXPIRES_AT and MICROCOSM_COLLECTOR_ORIGIN set, CollectorSession returns that token and never reads or exchanges a Hugging Face credential. A session for another scope (the Hugging Face module's artifacts:submit session) has no credential (missing), because the token only writes telemetry. The token is sent only to the collector origin it was obtained from (see "The Orrery module and a given token").
  3. The origin variable is only checked. The session compares MICROCOSM_COLLECTOR_ORIGIN with the origin the module settings resolved (unchanged: PRODUCTION_COLLECTOR_URL, a trusted collector_origin, or a loopback development URL). A different origin, a missing variable, an expiry without a time zone, or a token that is not visible ASCII without spaces raises GivenCollectorTokenError. That stops the whole emitter, not only telemetry: whichever of the telemetry and Orrery modules the host starts first prints The Microcosm emitter cannot start: <reason>. It exits before accepting any request from the build. (naming the variable, never its value), before opening its database. The host then closes any module it had started and exits with code 1 before accepting a request, so no module of that emitter, the Hugging Face module included, serves the build; the interface's start_emitter raises EmitterStartupError (child_exit). An emitter that loads only the Hugging Face module starts, with collector submissions off.
  4. Expiry and 401 leave runs pending. A given token cannot be renewed where it is used. From 30 seconds before expires_at (TOKEN_REFRESH_MARGIN_SECONDS, when an exchanged token is renewed; the collector allows no clock leeway), or after a 401 on registration or events, it is never sent again; each run stays pending with the new reason collector_token_expired or collector_token_rejected, instead of local_only, which is permanent. One line on standard error says so, and the module's drain at shutdown ends at once instead of holding the build's exit for the drain period. A later emitter with a Hugging Face login of the same user delivers the run; with 0.11.0 or later, its successful registration also clears the reason.
  5. microcosm-emitter version also prints collector_origin: the origin the emitter sends to without a module setting. The hello answer is unchanged.

The command's name

telemetry-token names what the command prints and the only thing the token allows. The design called it delegate; I didn't use that, because it describes an intent rather than the output, and it suggests the emitter grants authority when the collector does. It also reads like version: a noun for what is printed.

Exit codes of telemetry-token

Exit code Meaning
0 The token is printed
2 Usage error (a lifetime below 1, or a --development-collector-url that is not loopback); nothing was sent
3 No Hugging Face credential, or a login saved by hf auth login that this user cannot read; nothing was sent
4 The collector refused the credential (401, or 403 for a user outside the PolicyEngine organization), or the credential contains characters other than visible ASCII, such as a space or a line break, which cannot be sent as a bearer token, so it was not sent
5 The collector was unreachable, its answer was not valid HTTP, or its answer had no non-empty access_token and positive integer expires_in
6 The collector answered 422: its strict exchange body has no lifetime_seconds, so it predates tokens for other machines
7 New. An unexpected error; only the error's type is printed

A usage error prints argparse's usage text. Every other failure prints one line on standard error, without the token, the Hugging Face credential, the collector's answer body or an exception's text. An unsendable credential and an unreadable saved login each have their own fixed message. exchange_huggingface_credential checks the credential before sending it, and _http_post still refuses any bearer value that is not visible ASCII without spaces with a fixed message, so http.client never quotes one in its own error.

One exchange for the session and the command

exchange_huggingface_credential in core.auth is the only code that posts to the exchange, maps its statuses and checks a 200 answer; CollectorSession and telemetry-token both call it. Both now use the command's check: a non-empty access_token and a positive integer expires_in. For the session that is stricter than before (it defaulted a missing lifetime to an hour and read one sent as text); every collector release sends an integer expires_in. A collector answer that is not valid HTTP (http.client.HTTPException) counts as no answer.

The exchange returns ExchangedToken, whose token and lifetime are always set, or ExchangeFailure, with the reason and the HTTP status. The session maps each failure reason to its own answer through one table, _FAILED_EXCHANGE_ANSWERS, rather than passing the exchange's result on as its own. Session answers are typed: SessionCredential, a named pair of a SessionResult literal and the token, which unpacks as before and leaves the token out of its repr. The repository runs no type checker (its quality check is Ruff only), so these types were checked by reading.

An unsendable Hugging Face credential counts as refused. A credential that is not visible ASCII, such as one with a pasted space, a tab, a line break or a non-ASCII character, is never sent: the exchange answers unsendable, and so does the session. Such a value cannot be a bearer token (RFC 6750), so the collector would refuse it: before this PR the emitter sent one with a space and the collector answered 401, and one with a line break made http.client raise an error quoting the whole header value. Each caller handles it as a refused credential:

  • The workstation's telemetry module makes the run local_only with huggingface_credential_rejected and keeps recording events, instead of stopping on a ValueError. Its warning, printed once, says the credential contains characters that cannot be sent as a bearer token, and where to check, rather than that it was not accepted as a PolicyEngine organization member.
  • Collector submissions end the hand-over failed with CREDENTIAL_REJECTED.
  • The Orrery module's jobs stay queued with credential_unsendable, and are retried as for credential_rejected.

A given collector token with such characters is still refused at start.

The Orrery module and a given token

The Orrery module receives its credential through the collector session (shared_session), and sends it to its publication_origin setting, which by default is another service, not the collector. With a given token, the session hands the token only to a caller whose destination is the collector origin it was obtained from, so Orrery sends the given token only to the emitter's resolved collector origin: with any other publication origin, its default included, the session answers withheld_from_destination, and Orrery's jobs stay queued with credential_withheld_from_destination, so a receipt says the token was withheld on purpose rather than looking like a machine without a login. Today that loses nothing, since that service cannot yet authorize a publication with a collector token.

A 401 ends a given token only for the module that received it. Modules still share one session, and so one exchanged token and its renewal. With a given token there is nothing to renew, so shared_session gives each module a session of its own: a 401 that Orrery receives from its publication origin or from the storage it uploads to no longer stops telemetry.

Where the pending reason is stored

In the run's existing local_only_reason column, while upload_state stays pending. A new column or a new upload state would need a spool migration, and released emitters that share the spool database refuse a revision they don't know. Released emitters only look at upload_state, so they deliver these runs normally.

Environment variables

All three are new. None of them chooses where the emitter sends: the destination is still resolved only from PRODUCTION_COLLECTOR_URL, the trusted collector_origin setting or a loopback development_collector_url, and MICROCOSM_COLLECTOR_ORIGIN is only compared with it.

Variable Who sets it Who reads it Value
MICROCOSM_COLLECTOR_TOKEN The Microcosm Modal runner, from telemetry-token's access_token (tracker task 6.4 · 3, not written yet) core.auth.given_collector_token, through every CollectorSession in the emitter host. The telemetry module sends the token; the Orrery module only if it publishes to the collector origin; the Hugging Face module's submission session never The token; never printed or logged
MICROCOSM_COLLECTOR_TOKEN_EXPIRES_AT The same runner, from expires_at The same ISO 8601 time with a time zone
MICROCOSM_COLLECTOR_ORIGIN The same runner, from collector_origin The same; compared with the resolved origin only An origin

Provider and wiring: unverified until the runner exists. The design passes them as a Modal secret created for each launch (modal.Secret.from_dict), exempted from the runner's credential removal, and inherited by the build and its emitter child process. Nothing in this repository sets them. The tests' credential isolation fixture now clears them, as it clears HF_TOKEN.

Depends on

  • Collector task 4.3 · 1 (microcosm-collector, lifetime_seconds on the exchange, in progress) must be deployed before telemetry-token works against the deployed collector. Until then the deployed collector answers 422 to the new field and the command exits 6. The command is tested only against the loopback fake collector in tests/support/fake_collector.py, which now accepts lifetime_seconds with telemetry:write only, caps it at 24 hours, answers 422 when told to behave like an older collector, and can answer every exchange with a chosen 200 body.
  • The collector's own token-expiry check and its 401 for an expired or re-signed token are assumed, not tested here.

Overlaps

Open #5, #6 and #7 (stacked) change local-only handling in telemetry/service/collector.py. This PR doesn't change their code paths: it adds two branches to _collector_token (for an unsendable credential and for an unusable given token), one call in _register's 201 path, and the new _keep_pending. Whichever merges second merges main in; the conflicts should be limited to those spots.

Tests

Red, then green, for each behaviour (Python 3.14, macOS):

Behaviour Red commit and result Green commit and result
version reports the collector origin 5b72282: 1 failed (no collector_origin) f88eb20: passed
telemetry-token and its exit codes 83661c7: 7 failed (invalid choice: 'telemetry-token') c420d4f: 7 passed
A given token replaces the Hugging Face exchange; an unusable one stops telemetry at start 24717bd: 4 failed (the Hugging Face credential was read; no ValueError) a068d13: passed
Expiry and 401 leave runs pending for a later emitter; the drain ends at once 666c8c8: 4 failed (expired token still sent; no reason recorded; drain waited 5 s) 5446fae: passed
The session checks a 200 exchange answer as the command does (empty token, no, zero or text lifetime) 4ff2e43: 4 failed (the session answered ok; the command already exited 5) 21a0839: 4 passed
No failure prints a credential or carries it in its text; usage errors exit 2 ac205df: 2 failed (the command raised ValueError: Invalid header value quoting the credential; a given token with a line break passed the start check); the two usage cases passed 40e6660: passed
A given token counts as expired 30 s early 34a9647: 1 failed (a token with 10 s left was sent) bf4c7dc: 3 passed
Orrery sends a given token only to the collector, and a 401 ends it only for Orrery e85d6a7: 1 failed (Orrery got the token for its default publication origin; with that assertion removed, telemetry answered token_rejected after Orrery's 401) 5a7a450: passed
The start error says the whole emitter stops, whichever module starts first 7d4d810: 4 failed (the line said "Microcosm telemetry cannot start"; the Orrery module printed nothing) 832f6ea: 4 passed
Round 2. An unsendable Hugging Face credential is handled like a refused one: the run becomes local_only, nothing is sent, delivery keeps running 59a8e5a: 1 failed, 1 passed (ValueError from _http_post's check, raised through CollectorDelivery.flush_once) 17a9b53: 2 passed
Round 2. telemetry-token: an unsendable credential exits 4 with its own message, an unreadable saved login 3, an answer that is not HTTP 5, and an unexpected error still 7 9a5d25d: 3 failed, 14 passed (all three exited 7, with KeyError, PermissionError and BadStatusLine) ce0c1e2: 17 passed
Round 2. Orrery's session says a given token is withheld from its publication origin 0bb0492: 1 failed ('missing' != 'withheld_from_destination') ff9e209: passed
Round 3. The telemetry warning for an unsendable credential gives its own cause fd1e0b5: 1 failed, 1 passed (the warning said the credential "was not accepted as a PolicyEngine organization member") 45f0149: 2 passed

Commits without a behaviour test: 7b7c052 sets the three variables through one test helper (tests/support/given_token.py); 6191d15 types the session's answers and removes the session's unused environment parameter; b080f9e words the start error; 25d40c4 updates the README, the changelog fragment and docstrings. In round 2: 25551bb splits the exchange's result into success and failure types; 96bee3e tests an expiry without a time zone and the token's absence from the session's repr (both held already); ca37144 corrects the changelog and the README. In round 3: f44f9b7 tests that collector submissions refuse an unsendable credential without sending it, and that ExchangedToken's repr leaves the token out (both held already; each test fails with the code it covers removed); a6cf212 says what an unsendable credential is and why it is refused, in the command's message, the comments, the README and the changelog, which also name the new warning and credential_unsendable.

At a6cf212: uv run --no-sync pytest -m "not collector" passed in full (809 passed, 9 deselected), and tools/check-quality.sh and tools/check-changelog.sh passed. All 10 CI checks pass on a6cf212. The collector job's pinned collector predates scopes and lifetime_seconds, so this PR adds no collector-job test.

Follow-up

#37 tracks two problems older than this PR, which it leaves as they are: a saved login that cannot be read, or a malformed collector answer during delivery, still stops the workstation telemetry module; and the exchange accepts a collector token that contains a space.

Release

The fragment is added, so merging releases 0.11.0 of both distributions to PyPI.

🤖 Generated with Claude Code

anth-volk and others added 9 commits October 28, 2026 17:59
The Modal runner will compare this origin with the origin a collector
token was obtained for. Fails: `version` prints only the versions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The origin is the one the emitter resolves with no module setting, the
same one `resolve_collector_origin()` gives the modules. The `hello`
answer is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The command exchanges the operator's Hugging Face login for a
`telemetry:write` collector token with a requested lifetime, and prints
the token, its expiry and the collector origin as JSON on standard
output only. Each failure has its own exit code: no login (3), a refused
login (4), an unavailable collector (5), and a collector without
`lifetime_seconds` (6, its strict exchange body answers 422).

The fake collector now takes `lifetime_seconds`, as the collector's
exchange for builds on other machines will. Fails: there is no
`telemetry-token` command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It exchanges this machine's Hugging Face credential, at the collector
origin the emitter resolves, for a token with scope `telemetry:write`
and the requested `lifetime_seconds`. It prints `access_token`,
`expires_at` (counted from before the request, so never later than the
token's real expiry) and `collector_origin` as JSON on standard output,
and nothing else. A failure prints one line on standard error, without
the token or the collector's answer, and exits 3 (no credential),
4 (credential refused), 5 (collector unavailable) or 6 (the collector
answers 422 to `lifetime_seconds`).

`--development-collector-url` accepts a loopback collector only, as the
module setting of the same name does; no option or environment variable
can send the credential anywhere else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With MICROCOSM_COLLECTOR_TOKEN, MICROCOSM_COLLECTOR_TOKEN_EXPIRES_AT and
MICROCOSM_COLLECTOR_ORIGIN set, as the Modal runner will set them from
`telemetry-token`'s output, telemetry delivery must send that token and
never read or exchange a Hugging Face credential, and a session for
another scope must have no credential. The telemetry module must refuse
to start, before it opens the spool and with a message naming the
variable, when the origin differs from the one it resolved, a variable
is missing, or the expiry is unreadable.

Fails: the emitter ignores the variables and reads the Hugging Face
credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`CollectorSession` reads MICROCOSM_COLLECTOR_TOKEN,
MICROCOSM_COLLECTOR_TOKEN_EXPIRES_AT and MICROCOSM_COLLECTOR_ORIGIN.
With them, it returns that token and never reads or exchanges a Hugging
Face credential; a session for another scope, such as collector
submissions, has no credential, because the token only writes telemetry.

MICROCOSM_COLLECTOR_ORIGIN is only compared with the origin the session
was created for, which the module settings resolve as before. A
different origin, a missing variable or an unreadable expiry raises
`GivenCollectorTokenError`, whose message names the variables and never
their values. The telemetry module prints why and refuses to start,
now before it opens the spool, because the host prints only a fixed
startup failure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A given token cannot be renewed where it is used. Once it has expired,
or the collector answers 401, the run must stay pending, recording
`collector_token_expired` or `collector_token_rejected`, rather than
become local-only, which is permanent; the token must not be sent again,
and one line must say so. A later emitter with a Hugging Face login must
then deliver the run and clear the reason. The telemetry module must not
hold the build's exit for the drain period while it waits for such a
credential.

Fails: an expired token is still sent, a refused one is sent again with
no reason recorded, and the module waits out the drain period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A given token cannot be renewed where it is used. After it expires, or
after a 401, the session answers `token_expired` or `token_rejected`
instead of sending it again. Telemetry delivery then leaves each run
pending and records `collector_token_expired` or
`collector_token_rejected` in the run's existing `local_only_reason`
column, so no spool migration is needed, and says once on standard
error that the events wait for an emitter with a Hugging Face login.
`local_only`, which is permanent, is no longer reached this way.

A successful registration clears the reason, so a later emitter that
delivers the run leaves no stale reason. The module's drain at shutdown
stops as soon as nothing more can be delivered with the given token,
instead of holding the build's exit for the drain period.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds the README section on `telemetry-token`, its exit codes, the three
variables an emitter on another machine is given, and what happens when
the token stops working, and an `added` changelog fragment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 14 commits October 28, 2026 18:52
The three test modules each set MICROCOSM_COLLECTOR_TOKEN,
MICROCOSM_COLLECTOR_TOKEN_EXPIRES_AT and MICROCOSM_COLLECTOR_ORIGIN in
their own way, and two defined the synthetic token. They now use
`given_token_variables` in tests/support/given_token.py, and the
credential isolation fixture clears the three variables as it clears
HF_TOKEN. No test changes what it checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`telemetry-token` refuses a 200 exchange answer without a non-empty
token and a positive integer `expires_in` (exit 5), but
`CollectorSession` defaults a missing lifetime to an hour, raises a zero
one to a minute, reads one sent as text, and accepts an empty token. The
two must share one check. The fake collector can now answer every
exchange with a chosen 200 body.

Fails: the session answers `ok` in all four cases.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`exchange_huggingface_credential` now holds the request, the status
mapping and the check of a 200 answer that `CollectorSession` and
`telemetry-token` each had, with different rules. Both use the
command's stricter check: a 200 answer needs a non-empty `access_token`
and a positive integer `expires_in`. Every collector release sends an
integer `expires_in`, so the session's default of an hour is gone with
`DEFAULT_TOKEN_LIFETIME_SECONDS`; it still reuses a token for at least
`MINIMUM_TOKEN_LIFETIME_SECONDS`. The result is a typed `TokenExchange`.

`exchange_telemetry_token` and its `post`, `credential` and `clock`
parameters, which nothing passed, are gone: the command calls the
shared function and formats `expires_at` from the time it recorded
before the request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`telemetry-token` maps only `OSError` and `TimeoutError`. A Hugging Face
credential with a line break makes http.client raise a `ValueError`
whose text quotes the whole Authorization header, and the command exits
1 with a traceback. Any failure outside the mapping must exit with a
documented code (7), print nothing about the error but its type, and
send nothing. A given collector token with a line break passes the start
check today and fails the same way on its first request; it must stop
the emitter at start, naming the variable and not its value.

The failure test now also covers usage errors (exit 2), which already
pass.

Fails: the command raises `ValueError: Invalid header value b'Bearer
<the credential>'`, and the start check does not raise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`main` now catches any exception from `telemetry-token` and exits 7 with
one line naming only the error's type; nothing else about it is printed,
since its text can quote the credential.

Two checks keep credentials out of exception text as well:
- `_http_post` refuses a bearer credential that is not visible ASCII
  without spaces, with a fixed message, before http.client can quote it
  in its own error. Every Hugging Face credential and collector token
  has that form.
- The start check refuses a given `MICROCOSM_COLLECTOR_TOKEN` of any
  other form, naming the variable and not its value, as it refuses the
  other unusable values.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The collector decodes tokens with no clock leeway, and the session sends
a given token until the second it expires, so requests in its last
seconds get a 401 and the run records `collector_token_rejected`
instead of `collector_token_expired`. The exchanged-token path stops
`TOKEN_REFRESH_MARGIN_SECONDS` (30 s) early; a given token must too.
The expiry row of the pending-run test now uses a token with 10 s left.

Fails: the token is still sent, and the first delivery succeeds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The session stops sending a given token `TOKEN_REFRESH_MARGIN_SECONDS`
before `expires_at`, as early as it renews an exchanged one. Requests
that would have reached the collector in the token's last seconds, or
from a machine whose clock is slightly behind, now leave the run pending
with `collector_token_expired` instead of drawing a 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Orrery module gets its credential from the session it shares with
telemetry, and sends it to its `publication_origin`, by default another
service. With a given token that means a token lasting up to a day goes
to a service other than the collector it was obtained from. And a 401
from that origin, or from the storage Orrery uploads to, ends the token
for telemetry as well, although the collector never refused it.

With a given token, Orrery must have no credential (`missing`) unless it
publishes to the collector origin, and a 401 it receives must end the
token for Orrery only.

Fails: Orrery gets the token for its default publication origin; with
that assertion removed, telemetry answers `token_rejected` after
Orrery's 401.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two changes for a collector token given in MICROCOSM_COLLECTOR_TOKEN:
- **Only to the collector.** `CollectorSession` takes the `destination`
  its caller sends the credential to. The Orrery module passes its
  `publication_origin`; unless that is the collector origin, a session
  with a given token has no credential (`missing`), so Orrery's jobs
  stay queued with `credential_missing` and the token never leaves for
  another service. Exchanged tokens are unaffected.
- **A 401 ends it for one module.** `shared_session` still shares one
  session, and so one exchanged token, among modules. With a given
  token it gives each module a session of its own, since there is
  nothing to renew: a 401 one module receives ends the token for that
  module only.

The Orrery module now opens its queue after this check, as the
telemetry module opens its spool after it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unusable given token makes `initialize` raise in the telemetry or
Orrery module, and the host then exits before any module runs, not only
telemetry. The telemetry module's line says "Microcosm telemetry cannot
start", and the Orrery module, which the host may start first, prints
nothing. Each must print a line that says the emitter cannot start and
names the variable, before opening its database.

Fails: the telemetry module's line names only telemetry; the Orrery
module prints nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`shared_session` now prints the reason, so the telemetry and Orrery
modules both say why, whichever the host starts first. The line says
the emitter cannot start and exits before any of its modules runs,
which is what the host does after any failing `initialize`, rather than
that telemetry cannot start.

The origin error no longer points only at `microcosm-emitter version`:
that prints the origin used without a module setting, and a
`collector_origin` or `development_collector_url` setting chooses
another.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`CollectorSession.credential()` returns a `SessionCredential`: a named
pair of a `SessionResult` (a `Literal` of the seven answers, including
`token_expired` and `token_rejected`) and the token. It unpacks as
before, and its repr leaves the token out. The telemetry delivery types
its map from session answers to pending reasons with it, and the Orrery
queue and the submission client name it in their `credential`
parameters, so each module that reads these answers can see them.

The session's `environment` parameter, which nothing passed, is gone:
it reads the process environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When a module's `initialize` fails, the host closes the modules it has
already started, which may include the Hugging Face module and its
workers, and exits before accepting a request. "Before any of its
modules runs" was not exact; the start error now says what the build
sees.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README section, the changelog fragment and the docstrings now say:
- the Orrery module gets its credential through the shared session, and
  with a given token has none unless it publishes to the collector
  origin;
- an unusable value in any of the three variables stops the whole
  emitter at start, with the exact line printed, and what the host and
  the interface then do; an emitter with only the Hugging Face module
  starts with collector submissions off;
- a given token counts as expired 30 seconds early, and a 401 ends it
  only for the module that received it;
- `telemetry-token` exits 7 for any error the other codes do not name,
  usage errors print the usage text, and no output contains a
  credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
…-spool

Brings in #34's round-one changes, up to 25d40c4.

Conflicts:
- README.md: #34's rewritten bullets on given tokens, followed by this
  branch's section on a copied spool.
- src/microcosm_emitter/cli.py: both sides' imports, codes and messages.
  `telemetry-token` uses #34's `exchange_huggingface_credential` and
  its exit 7 for unexpected failures, with this branch's
  `--development-collector-url` argument and `CREDENTIAL_FAILURES`
  name. Both sides use exit 7 for different failures; the following
  commits give the spool errors their own code.

#34 removed `CollectorSession`'s `environment` parameter and
`exchange_telemetry_token`, which this branch used:
- `deliver_spool` now builds `CollectorSession(origin)`. That session
  reads a given token from the environment, so `deliver` now uses a
  build's token left in the shell; the next commit stops that. The
  existing test fails: 1 failed, 24 passed in the two command test
  modules (`assert 5 == 8`).
- The copied-spool tests obtain the build's token with
  `exchange_huggingface_credential`, and set the given-token variables
  with #34's `given_token_variables`, only while the build's session is
  created.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
#34 removed `CollectorSession`'s `environment` parameter, through which
`deliver_spool` kept the session from reading
MICROCOSM_COLLECTOR_TOKEN and its two companion variables. Since the
merge, a build's token left in the operator's shell was sent instead of
the operator's login: the run stayed pending with
`collector_token_expired` until the time limit, and `deliver` exited 5.

`CollectorSession` now takes `use_given_token`, true by default. With
false, which only `deliver_spool` passes, it reads none of the three
variables and exchanges this machine's Hugging Face credential.

Before: 1 failed, 24 passed (`assert 5 == 8`). After: 25 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
#34 merges first, and its `telemetry-token` exits 7 for any failure it
does not name. `status --spool` and `deliver --spool` also exit 7 for a
file they cannot use as a spool database, so the two meanings share a
code. The spool file errors move to 9, the next code neither command
uses.

Fails: 6 failed, 5 passed (`assert 7 == 9`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
The commands' exit codes are now members of one `ExitCode` table in
`cli.py`, marked `@unique`, which refuses two members with one value
when the module is imported. A new code therefore cannot take a meaning
another command already gives it, as exit 7 did when #34's unexpected
failures met this branch's spool file errors. The table also reserves
1 (the host's start failure, and Python's code for an uncaught
exception) and 2 (argparse's usage error).

`status --spool` and `deliver --spool` exit 9 for a file they cannot use
as a spool database; 7 keeps #34's meaning, and `deliver` keeps 8 for a
run another Hugging Face user registered. The README says so.

Before: 6 failed, 5 passed (`assert 7 == 9`). After: 11 passed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
`deliver_spool` declared its own `LoginFailure` literal for three of
the session's answers, from before #34 typed them. `SpoolDelivery` now
has the outcome `login_failed` and carries the session's answer in
`login`, typed with #34's `SessionResult`. Any answer other than `ok`
stops `deliver` before it opens the file, where only the three named
answers did. `deliver` maps the answer to its exit code and message
with the same `CREDENTIAL_FAILURES` table as `telemetry-token`.

No behaviour changes for the answers a session without a given token
or a scope gives: 25 passed in the two command test modules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 9 commits October 28, 2026 19:52
exchange_huggingface_credential now returns ExchangedToken, whose token and
lifetime are always set, or ExchangeFailure with the reason and HTTP status.
CollectorSession maps each failure reason to its own answer through one
table, instead of passing an exchange result through as a session result:
lifetime_unsupported, which the session never causes, counts as unavailable.
No behaviour changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A credential with a pasted space must be handled like one the collector
refuses: the run becomes local-only with huggingface_credential_rejected,
nothing is sent, and the delivery keeps running. Today the request's check
for values no HTTP header can carry raises ValueError through
CollectorDelivery.flush_once, which stops the telemetry module.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
exchange_huggingface_credential checks the credential before it sends it,
and answers unsendable for one an HTTP header cannot carry, such as one with
a pasted space. CollectorSession counts that as rejected, which is what the
collector answered such a credential (401) before the emitter checked: the
telemetry module makes the run local-only with
huggingface_credential_rejected, says so once, and keeps recording events,
instead of stopping on a ValueError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An unsendable credential exits 4 with its own message, an unreadable saved
login exits 3, and a collector answer that is not HTTP exits 5. Exit 7
stays for unexpected errors, which print only their type. Today all three
exit 7: the first with a KeyError, the others with PermissionError and
BadStatusLine, which are neither an OSError nor caught. The fake collector
can now answer an exchange with bytes that are not an HTTP answer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…odes

- An unsendable credential exits 4, as a refused one, with a fixed message
  that says it was not sent and why.
- A saved login that huggingface_hub cannot read exits 3, naming only the
  error's type.
- A collector answer that is not valid HTTP (http.client.HTTPException)
  counts as unavailable: exit 5 here, and a retry in the emitter's sessions
  instead of an uncaught error.

Exit 7 is left for unexpected errors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a given collector token, the Orrery module's session answers missing
for its publication origin, so its jobs record credential_missing, the code
of a machine without a login. The answer should say that the token is
withheld from that destination on purpose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A session holding a given collector token now answers
withheld_from_destination, not missing, to a caller that sends its
credential to another destination. The Orrery module's jobs then record
credential_withheld_from_destination, so a receipt from a Modal build says
that the token was kept from the publication origin on purpose, rather than
looking like a machine without a login. Another scope still answers
missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… repr

An expiry time without a time zone stops the emitter at start, naming
MICROCOSM_COLLECTOR_TOKEN_EXPIRES_AT, as the README says. The session's
answer and the given token it holds leave the token out of their repr.
Both already hold; these cover them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The changelog no longer says no output has a credential: the command
  prints the token on standard output. It names the exception for an emitter
  with only the Hugging Face module, the code Orrery's jobs record, and that
  an unsendable Hugging Face credential counts as refused.
- The README's exit-code table lists an unreadable saved login under 3, an
  unsendable credential under 4 and an answer that is not valid HTTP under
  5, leaving 7 for unexpected errors. It names
  credential_withheld_from_destination, and says that only an emitter of
  0.11.0 or later clears a pending run's reason.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
…-spool

Brings in #34's round 2 fixes up to ca37144. Two files conflicted; both
sides are kept:

- cli: #36's ExitCode table and messages, with #34's unsendable-credential
  entry (exit 4), unreadable-login message (exit 3) and ExchangeFailure
  check. CREDENTIAL_FAILURES is typed by the exchange's failure reasons and
  the session's answers, which telemetry-token and deliver look up.
- README: #34's corrected paragraph on a token that stops working, followed
  by #36's section on a copied spool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
#34 makes a Hugging Face credential that an HTTP header cannot carry count
as refused, and never sends it. deliver gets that answer from its session as
rejected, so it exits 4 but says the collector refused the credential, which
never reached the collector. It should print telemetry-token's message for
an unsendable credential.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk and others added 4 commits October 28, 2026 20:42
A Hugging Face credential with a pasted space is never sent, but the
workstation telemetry module warns that it "was not accepted as a
PolicyEngine organization member". The test now checks each case's
warning: a refused credential keeps that message, and an unsendable one
must say that it contains characters that cannot be sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… sent

CollectorSession now answers unsendable, instead of rejected, for a Hugging
Face credential it did not send because it contains characters that cannot
be sent as a bearer token. The workstation telemetry module still marks the
run local-only with huggingface_credential_rejected, but prints its own
warning naming that cause and the places to check. Collector submissions
still end the hand-over failed with CREDENTIAL_REJECTED. The Orrery
module's jobs record the answer as credential_unsendable and are retried,
as for credential_rejected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dToken's repr

The submissions client's refusal test now has a case for a credential
with a pasted space: the submission fails with CREDENTIAL_REJECTED and
the credential is never sent. A second test exchanges a credential with
the test collector and checks that the token is absent from the repr of
the ExchangedToken it returns. Both pass; with the refusal mapping or
repr=False removed, each fails.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs and messages said a space is a character "that an HTTP header
cannot carry", but HTTP header values may contain spaces. What the
emitter refuses is any credential that is not visible ASCII, such as one
with a space, a tab, a line break or a non-ASCII character: a bearer
token (RFC 6750) never contains those characters, so the collector would
refuse it, and Python's HTTP client raises an error that quotes the whole
header value for a line break.

The command's message, the request error, the comments, the README and
the changelog now say that the credential contains characters that
cannot be sent as a bearer token. The README and the changelog also
name the new telemetry warning and the Orrery module's
credential_unsendable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
…-spool

Brings #34 up to a6cf212: the unsendable credential's own telemetry
warning, the corrected wording for an unsendable credential, and the
tests for the submissions refusal and ExchangedToken's repr.

Conflicts:
- core/auth.py: both sides added the session answer `unsendable`; kept
  #34's comment, which says how each caller handles it, including
  Orrery's credential_unsendable.
- cli.py: kept #36's ExitCode table and CREDENTIAL_FAILURES, with #34's
  wording for the unsendable message and the comment above it.

Also resolved:
- telemetry/service/collector.py merged without a conflict but with
  #36's `result in ("rejected", "unsendable")` ahead of #34's own
  `unsendable` branch, so the new warning would never print; the
  rejected branch is `result == "rejected"` again.
- test_copied_spool checks the unsendable message's new wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk added a commit that referenced this pull request Oct 28, 2026
… login

The Orrery module records the session's answer as credential_<answer>,
so a graph job whose Hugging Face credential cannot be sent as a bearer
token waits with credential_unsendable, not credential_rejected. A
comment where the code is recorded now lists the credential codes,
beside the session comment, README and changelog that #34 updated.

The README and changelog also say that deliver stops, leaving the copy
unchanged, for a saved login it cannot read (exit 3), and the remaining
comments that said a space is something "an HTTP header cannot carry"
now say the credential contains characters that cannot be sent as a
bearer token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk marked this pull request as ready for review October 29, 2026 14:44
@anth-volk
anth-volk merged commit b969854 into main Oct 29, 2026
10 checks passed
@anth-volk
anth-volk deleted the claude/modal-telemetry-token branch October 29, 2026 14:44
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.

Obtain a telemetry-only collector token for a build on another machine, and use one the emitter is given

1 participant