Repository navigation
Conversation
SessionSigningKey reads the unencrypted PEM private key that TELEMETRY_JWT_SIGNING_KEY will hold, accepts only EC P-256 keys (the curve ES256 requires), and derives its key ID as the RFC 7638 SHA-256 thumbprint of the public key, so the ID cannot disagree with the key. public_jwk() gives the public half as the JWK the collector will publish. A refused key raises without the parser's error attached, and the private key is left out of repr(). cryptography becomes a direct dependency; PyJWT needs it for ES256. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Verification now reads the token header's algorithm to choose the key, then lets PyJWT accept only that algorithm: - ES256 verifies with the configured signing key's public key, and only when the header names that key's ID. - HS256 verifies with TELEMETRY_JWT_SECRET for as long as it is configured. - Anything else, including "none", another key ID, or an HS256 token keyed with the published public key, gets 401, which makes the emitter exchange again. The collector reads the ES256 key from TELEMETRY_JWT_SIGNING_KEY. Either it or TELEMETRY_JWT_SECRET must be set, so the HS256 secret can later be removed from the deployment without a code change. A key that cannot sign ES256 stops startup without showing its value. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…gured With TELEMETRY_JWT_SIGNING_KEY set, the token exchange signs with ES256 and names the key in the kid header; without it, tokens stay HS256 as before. GET /.well-known/jwks.json publishes the public key, without credentials and cacheable for five minutes, and lists no keys before a signing key is set. A verifier holding only that key set accepts the collector's tokens. The README describes the tokens, the switch window, how to retire HS256 by no longer mounting TELEMETRY_JWT_SECRET, and how to rotate the key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SessionScope names the two scopes a session token can carry, one per token. _issue_session_token takes the scope to issue and _decode_session_token the scope an endpoint requires, refusing any other with that scope's 403 message. The telemetry endpoints require telemetry:write, and the exchange still issues only telemetry:write tokens. This is the same code as the scope change in #12, which adds the exchange's scope request and the artifacts:submit endpoints, so that change applies on top unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each environment's deploy job reads TELEMETRY_JWT_SIGNING_KEY_SECRET_NAME and passes it to scripts/deploy.sh as JWT_SIGNING_KEY_SECRET_NAME. The script requires it, checks that the Secret Manager secret exists before changing traffic, and mounts its latest version as TELEMETRY_JWT_SIGNING_KEY in both the maintenance and candidate revisions, beside TELEMETRY_JWT_SECRET, which stays mounted for the switch window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Cloud Run reads a secret mounted as an environment variable when each instance starts. Adding a version to the mounted signing-key secret, as the README said to, gave every instance the serving revision started afterwards the new key while its running instances kept the old one, so tokens failed with 401 between instances until the next deployment. The README now rotates the key by creating a new secret and pointing TELEMETRY_JWT_SIGNING_KEY_SECRET_NAME at it, which leaves the serving revision on the old secret. It also names the two roles a new key's secret needs on the secret itself: secretAccessor for the runtime service account and viewer for the deployment identity's existence check. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…imes The issuer, audience, expiry and required-claim checks were tested only for ES256 tokens, although the HS256 tokens issued before the switch are the ones that keep arriving during it. A token that is not yet valid, by nbf or iat, was refused only through PyJWT's defaults, with no test to hold that. Each check now runs for both algorithms, and the not-yet-valid cases are included. A missing claim must raise MissingRequiredClaimError naming it. Test-only: the behaviour already held, so these tests passed on first run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
that referenced
this pull request
Oct 20, 2026
The runbook listed only part of what #12, #16 and #19 need in the new project, so following it after any of them merges would stop a deployment or lose writes: - #12's worker job and recovery job write to the database while the service is in maintenance mode, so the export could miss their writes. The runbook now pauses the queue and the recovery schedule before the export, resumes them on rollback, and lists the recovery job, its schedule and the Cloud Scheduler API. - #16's signing key is a fifth secret that the deployment checks and mounts. The runbook now creates it, grants it like the other four and lists it with the settings. - #19's daily check wasn't mentioned. The runbook now lists its job, schedule, alert policies, permissions and three production variables, one of which names the project. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
that referenced
this pull request
Oct 20, 2026
Step 3.2 now reads the access policy of every secret the environment uses, including #16's signing key and S1's token if merged, and records #12's queue, worker and recovery jobs, schedule, bucket roles and invoker account, and #19's check job, schedule, alert policies and notification channel. Section 11 now retires those resources: every recorded secret, the old worker account, #12's queue, jobs, schedule and accounts (but not the intake bucket, which stays in use), and #19's job, schedule, account, policies and channel. S1's old token is revoked only after step 6.7 has paused the old check that uses it. Documentation only, so there is no failing test to add first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
that referenced
this pull request
Oct 20, 2026
#16 adds the signing-key secret to both environments' deployments, so without it that environment's deployment stops, not only production's. The quiet-day figure counted merge to tag only (25 minutes on 2026-10-15). The two deploy jobs add about 10 minutes, and the export and import come on top, so the runbook's section 6 and the memo's D2 B now say about 40 minutes. Documentation only, so there is no failing test to add first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 28, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #15
Hold until the collector's deployment switch-over is verified. After that, every merge to
maindeploys staging and then production. This PR also needs the manual steps below done in both environments first.Summary
TELEMETRY_JWT_SIGNING_KEYholds an EC P-256 private key, the token exchange signs session tokens with ES256 and names the key in thekidheader. Without it, tokens stay HS256, exactly as today.GET /.well-known/jwks.jsonpublishes the public key as a JSON Web Key Set, without credentials.TELEMETRY_JWT_SECRETstays configured.telemetry:writeorartifacts:submit, and each endpoint requires its own. This uses the same code as Accept artifact submissions into private storage and run worker jobs #12.Design
latestversion to the service asTELEMETRY_JWT_SIGNING_KEY. Other curves, RSA, Ed25519, public keys and encrypted keys stop startup, and the error never includes the value.kidGET /.well-known/jwks.jsonreturns{"keys": [{"kty": "EC", "crv": "P-256", "x", "y", "kid", "use": "sig", "alg": "ES256"}]}withCache-Control: public, max-age=300. Before a key is configured,keysis empty. Like every route except/health, it answers 503 while the maintenance revision serves.TELEMETRY_JWT_SECRETotherwise (local development, and #12's tests).algchooses the key, and PyJWT then allows only that algorithm. ES256 needs the configured key'skid; HS256 needsTELEMETRY_JWT_SECRETto be set. Everything else gets 401, includingnone, an unknown or missingkid, a token signed by another key under ourkid, and an HS256 token keyed with the published public key. 401 matters: the emitter exchanges again after a 401, but stops delivering a run after a 403. Issuer, audience, expiry and the required claims are checked as before.TELEMETRY_JWT_SECRET. It must last at least one session-token lifetime (TELEMETRY_SESSION_TOKEN_TTL_SECONDS, 1 hour) after the last HS256-issuing revision stopped serving, because that revision's tokens stay valid that long. How much longer to keep it, as rollback cover, is the team's call.CollectorSettingsaccepts either the key or the HS256 secret, and requires at least one. The follow-up removesTELEMETRY_JWT_SECRETfrom both--set-secretslists and removesJWT_SECRET_NAMEfromscripts/deploy.shandpush.yaml. After that deploys, the old variables and secrets can be deleted. The README documents these steps.TELEMETRY_JWT_SIGNING_KEY_SECRET_NAMEat it, and deploy. Don't add a version to the mounted secret: Cloud Run reads it when each instance starts, so instances of the serving revision would mix keys until the next deployment. Tokens signed by the previous key get 401 and are exchanged again.SessionScope = Literal["telemetry:write", "artifacts:submit"]inmodels.py,SCOPE_REFUSALS, ascopeparameter on_issue_session_token, and arequired_scopeparameter on_decode_session_token. The exchange still issues onlytelemetry:write. #12 adds the{"scope": "artifacts:submit"}exchange request and the endpoints that require that scope. Until then, nothing onmainissuesartifacts:submit.Who verifies the tokens. Only the collector does.
CollectorSession.credential()insrc/microcosm_emitter/core/auth.pykeepsaccess_tokenas a string forexpires_inseconds and never decodes it. Version 0.2.1, which builds pin, does the same.telemetry/service/collector.pycallssession.invalidate()and exchanges again.{"scope": ...}to the exchange body.X-Telemetry-Read-Token. Verifying consumers' Vercel OIDC tokens is a separate change (C6).Rollout order
COLLECTOR_DEPLOY_ENABLED, and Carry over calibration-diagnostics#219: apply each event batch incrementally #11 verified on its release commit.COLLECTOR_DEPLOY_ENABLEDisn't set today.main. The release deploys staging first, then production only if staging succeeds. Each new revision accepts HS256 and ES256 tokens and issues ES256 tokens./.well-known/jwks.jsonlists one key.How this meets the plan's order ("deploy dual acceptance before anything issues ES256-only tokens"):
Environment variables and secrets
TELEMETRY_JWT_SIGNING_KEY(new)CollectorSettings.from_environment)--set-secrets TELEMETRY_JWT_SIGNING_KEY=${JWT_SIGNING_KEY_SECRET_NAME}:latestindeploy_maintenanceanddeploy_candidateJWT_SIGNING_KEY_SECRET_NAME(new)scripts/deploy.sh:require_configuration,check_secrets_exist,deploy_maintenance,deploy_candidateTELEMETRY_JWT_SIGNING_KEY_SECRET_NAMEinstagingandproductionenv:ofdeploy-staginganddeploy-productioninpush.yaml, so every step that runs the script inherits itTELEMETRY_JWT_SECRET(unchanged; retired later)TELEMETRY_JWT_SECRET_NAMEPROJECT_ID,RUNTIME_SERVICE_ACCOUNT(unchanged)check-secrets-exist.sh,gcloud run deployTELEMETRY_GCP_PROJECT_ID,TELEMETRY_RUNTIME_SERVICE_ACCOUNTroles/secretmanager.secretAccessorfor the runtime service account on the new secretgcloud secrets describeincheck-secrets-exist.shTELEMETRY_GCP_DEPLOY_SERVICE_ACCOUNT) needsroles/secretmanager.vieweron it, as it has on each of the other secretsWhat fails, and where, if a step is missing:
--no-traffic, never startsIn each case staging fails first, and production doesn't run.
Manual steps (not done here)
For each environment, staging first:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | gcloud secrets create <name> --project <project> --data-file=-. Use a different key for each environment.roles/secretmanager.secretAccessoron that secret.roles/secretmanager.vieweron that secret. It has no project-wide Secret Manager role, so without this grant the deployment stops at "Check required Secret Manager entries exist".TELEMETRY_JWT_SIGNING_KEY_SECRET_NAMEto the secret's name in that GitHub environment.After the switch window (follow-up): delete
TELEMETRY_JWT_SECRET_NAMEfrom both environments and delete the HS256 secrets.Expected conflict with #12
Both PRs edit the token functions in
app.py. To keep the conflict small, this PR writes the scope change exactly as #12 does, so those hunks merge on their own. I merged this branch intoclaude/submission-intakelocally (not pushed) to find the actual conflicts:telemetry_collector/app.py, importsTokenExchangeRequestto themodelsimport; this PR adds thesession_tokensimport right after it.telemetry_collector/models.pySessionScopealias; #12 also addsTokenExchangeRequestright after it.uv.lockcffi,cryptographyandpycparser; #12 adds the Google client packages, which also bring incryptography.uv lockagain.Everything else merged without conflicts:
_issue_session_token,_decode_session_token,CollectorSettings,from_environment,pyproject.tomland the README.With those three resolutions, the combined code passes:
test_session_scopes.pyandtest_intake_settings.py, together with this PR's three token test files: 95 passed.#12's token tests construct settings without a signing key, so they still issue and decode HS256 unchanged. Once both merge, #12's
artifacts:submittokens are ES256 too.tests/test_session_token_scopes.py(the verification side) and #12'stests/test_session_scopes.py(the exchange side) overlap slightly. Whichever PR merges second can fold them together.Commits (red, then green)
I ran each commit's test files before the change (red) and after it (green), then again after committing. Failing tests ran against stubs that raise
NotImplementedError, so each test failed on its own rather than at import.test_settings_still_refuse_a_short_hs256_secret, which already held.test_exchange_issues_hs256_tokens_until_a_signing_key_is_configured, checks today's behaviour.test_exchange_still_issues_telemetry_write_tokensalready held.test_deploy_script.pyandtest_deployment_workflow.py.Testing
test_session_token_keys.py,test_session_token_signing.py,test_session_token_scopes.py,test_deploy_script.pyandtest_deployment_workflow.py. All keys are generated during the test run, and no test reads a stored key.tools/check-quality.sh(Ruff check and format check,bash -non scripts) passes, andtools/check-changelog.shpasses (0.3.0 to 0.4.0).test_app.py, even thoughapp.pychanged. Its settings have no signing key, so its HS256 decode still applies. CI covers it.Fixes after review
A review of
d992b4afound one documentation bug and two smaller gaps. Fixes, in order:808558dRotate the signing key through a new secret, and name both secret grants. Documentation only, so there is no red step.main, a token from one instance got 401 from another, and/.well-known/jwks.jsondepended on which instance answered.TELEMETRY_JWT_SIGNING_KEY_SECRET_NAME, which leaves the serving revision on the old secret.roles/secretmanager.secretAccessorfor the runtime service account, androles/secretmanager.viewerfor the deployment identity.b8934c2Test the registered-claim checks for HS256 tokens and not-yet-valid times. Test only; the behaviour already held, so there is no red step.nbfin the future andiatin the future.MissingRequiredClaimErrornaming that claim.Every test file this branch adds or changes, run together after the fixes: 115 passed.
tools/check-quality.shandtools/check-changelog.shpass.Review round 2026-10-22
A second review of
b8934c2found no code bug, so no commits were added. The earlier fixes (808558d,b8934c2) are confirmed.Corrections to the text above:
COLLECTOR_DEPLOY_ENABLEDhas beentruesince 2026-10-20, and both services run the9b32902release from this repository. So rollout step 1 is complete, and merging now releases and deploys.TELEMETRY_JWT_SIGNING_KEY_SECRET_NAME. If this PR merges first, the staging deployment stops at "Require deployment configuration" before any Google Cloud call, and so does every later collector deployment until the variable is set. Traffic is not changed.Checks:
maininto this branch (not pushed), the same files plustest_app.pypass: 147 passed.tools/check-quality.shpasses.mainmerges cleanly.tests/test_deploy_script.py. Whichever PR merges second resolves it.mainstill treat the token as opaque. They exchange again after a 401.X-Telemetry-Read-Token.artifacts:submitthrough the same_decode_session_tokensignature this PR adds.🤖 Generated with Claude Code