Repository navigation
Accept artifact submissions into private storage and run worker jobs - #12
Merged
Merged
Conversation
Add the request models for the submission contract: the two kinds, each kind's file roles with their counts and per-file size limits, the 256-file and 8 GiB totals, provenance (run, build, country, release and the Hugging Face repo, commit and folder), and a required licensed_data boolean with no default. Unknown fields, non-flat file names, folders with '.' or '..' segments, and loosely typed sizes, versions or booleans are refused. The submission, job and receipt identifiers are SHA-256 digests of canonical JSON, so an identical request always names the same submission. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add the intake switch: COLLECTOR_INTAKE_ENABLED must be exactly 1, and only
then does the collector read the intake bucket, the URL-signing service
account, the Cloud Tasks queue, the worker job and the service account the
queue uses to start it. A missing or malformed setting stops startup. With
the switch unset, as in every current deployment, intake stays off.
The token exchange accepts an optional body, {"scope": "artifacts:submit"}.
Without a body, or with {} as the emitter sends today, it still issues a
telemetry:write token. Asking for artifacts:submit while intake is off
returns 404. Each token carries one scope and each endpoint accepts one, so
a submission credential cannot write telemetry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Alembic revision 0002_artifact_intake with two tables. A submission row holds the kind, owner, licensed_data, provenance, the sorted inventory and the status. A job row records whether Cloud Tasks accepted it (enqueued_at, null until then) and which worker holds it, with an attempt count and a lease. Check constraints limit both status columns to the contract's values. Downgrading removes only these tables. The store creates a submission once and returns the same record to a repeat request by its owner, refusing another user. Accepting it records each object's generation and a queued job, once. A worker can lease a queued job, or re-lease one it or an expired lease held, so a Cloud Run task retry after a crash continues the same job while a duplicate execution cannot take it. A lease taken after the last attempt marks the job failed instead. The in-memory fake used by the endpoint and worker tests runs the same contract tests as the PostgreSQL store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Define the interfaces the intake endpoints and worker use: the intake object store (sign uploads, list a submission's objects, stream one generation of an object) and the worker queue (enqueue a job ID). GoogleCloudStorageIntake signs V4 PUT URLs through IAM signBlob with the runtime's own credentials, so no key file exists. Every URL's signature covers the declared SHA-256 as object metadata, an exact-size content length range and if-generation-match 0, so the storage service refuses an upload of another size and an object is created only once. Signing runs 16 at a time to keep a 256-file response inside the API's 30 s timeout. Listing reports each object's size, generation and recorded digest; reading requests the generation recorded at completion. CloudTasksWorkerQueue creates one task per job, named after the job ID. The task calls the Cloud Run Admin API to run the worker job with the job ID as an environment override, authenticated as the invoker service account. An existing task (409) counts as enqueued; any other failure is reported as a retryable unavailable queue. Adds google-cloud-storage, google-api-core, google-auth and requests as dependencies; nothing here uses gRPC. Tests use in-process stand-ins for the clients and make no network calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Mount the submission endpoints only while intake is switched on; with it off
they do not exist and are absent from the OpenAPI document, as before.
create_app refuses intake settings without the services they need, and the
deployed entry point builds the PostgreSQL store on the collector's existing
engine plus the Google Cloud adapters, importing those only when intake is on.
- POST /v1/submissions records the submission (201, or 200 for a repeat by
its owner) and returns a signed upload for each file not yet stored.
- POST /v1/submissions/{id}/complete lists the submission's objects and
answers 409 with each missing file, size mismatch or recorded-digest
mismatch. Otherwise it records the generations and a queued job, enqueues
it, and records that it did, so a repeat completion never enqueues twice.
If the queue is unavailable it answers 503 with Retry-After, and the next
completion enqueues the recorded job.
- GET /v1/submissions/{id} reports the status; GET .../receipt returns the
final record, with each file's generation and the receipt's own SHA-256,
or 409 before the submission is final.
Every endpoint needs an artifacts:submit token and serves only the
submission's owner. The router module gets the same Ruff B008 exemption as
app.py, for FastAPI's Depends defaults.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add python -m telemetry_collector.worker, which a Cloud Task starts as a Cloud Run Job execution with COLLECTOR_WORKER_JOB_ID set. It leases the job under the execution's name, streams every file at the generation recorded at completion and recomputes its size and SHA-256, then calls the handler registered for the submission's kind with the submission record and readers for its files. - Bytes that differ from the inventory, or an object that is gone, reject the submission before any handler runs; so does a handler raising SubmissionRejectedError. - Any other error returns the job to the queue and exits 1, so Cloud Run runs the task again; the third failed attempt fails the submission instead. - A job that is final, or leased to another execution, is skipped, so a repeated Cloud Task delivery does nothing. Both kinds start with a handler that only records the check, until the calibration-tree and Orrery workers register their own. The storage adapter no longer needs a signing account just to read. The README describes intake, its settings, and what switching it on needs: the bucket, queue, worker job, permissions and deployment wiring, none of which this change creates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Since the submission endpoints were added, create_app refuses intake settings without intake services. The token scope tests built an app with intake on and no services, so five of them failed. They now pass in-memory fakes whenever intake is on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CI found two existing tests the intake change broke. The ORM metadata test pinned the three telemetry tables; it now also expects artifact_submissions and artifact_jobs and their relationship to each other. The vocabulary test refuses the long form of "repo" in the collector's own text, which a submission-pattern constant used; it is now REPO_FOLDER_PATTERN. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in main (the Orrery projection, #9, and the 0.3.0 release commit) through the contract branch, so this branch is up to date as the ruleset requires. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in the contract's description of how the emitter's bundle roles map to the submission roles. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
that referenced
this pull request
Oct 20, 2026
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>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
build_id, release and each Hugging Face folder segment now follow the emitter interface's safe_identifier rule (128 characters, no colon), each repo part is at most 96 characters, and a folder at most 512. The digest of a submission's files is files_sha256 in the status, the receipt and the table, so it is never confused with the emitter receipt's inventory_sha256. SubmissionRecord uses the submission kind and status types, the status sets are defined once in the contract module, and the intake prefix comes from the same function as each object name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The token response now carries the token's scope, so a client can see that a collector released before intake answered an artifacts:submit request with a telemetry:write token. While intake is switched off, the exchange for artifacts:submit and every submission path answer 404 with the code intake_not_enabled, and an unknown submission answers 404 with the code submission_not_found, so neither is mistaken for the other. The other intake errors carry codes too: submission_owned_by_another_user, files_missing, submission_not_final and worker_queue_unavailable. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stored object of another size or recorded SHA-256 can never be replaced, because uploads may only create objects. Completion now rejects the submission with stored_object_mismatch instead of answering 409, which told the client to upload again into a 412. Create signs only missing files, and the status document reports the job once there is one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
google-cloud-storage 3.17.1 signs Content-Type and Host as well as the three x-goog headers, and adds both to the headers dict it is given. The adapter now gives the library its own copy and lists all five headers explicitly, with Host taken from the signed URL. A test signs with the real library, standing in only for the IAM signBlob request, and checks that the instruction lists exactly the URL's X-Goog-SignedHeaders. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An attempt killed for memory or by the task timeout, or an execution that exited before claiming, left its job processing or queued for ever: the worker failed a job only from inside its third attempt, a claim recorded attempts_exhausted only on a fourth claim, and the Cloud Task finished as soon as jobs.run started the execution. - Leases now last 65 minutes, longer than the one-hour task timeout, so an expired lease means its attempt has ended. - python -m telemetry_collector.operations recover, run every 10 minutes, fails a job whose lease expired after its third attempt (attempts_exhausted) or that would need a fourth dispatch (worker_unavailable), and dispatches again one whose attempt ended early or that stayed queued for 30 minutes. Each dispatch is a Cloud Task named <job_id>-<dispatch number>, so a repeated dispatch starts nothing new. - The job ID now hashes a processing round. reprocess records the next round for final submissions, by ID or with --kind and --intake-only, so intake-only submissions can be processed once K4 or O4 registers a handler, and stored Orrery inputs can be projected again. - PostgreSQL tests run completions, claims and recoveries concurrently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Calibration trees may be public, including those built from licensed data (D10). An Orrery artifact of a licensed build stays private until output clearance (D5); one of an unlicensed build may be public. Native evidence is never published. Handlers read the visibility from JobContext.output_visibility, so K4 and O4 apply one rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The migration job's user owns the tables it creates, and the runtime user is another role whose name the migration does not know. Migration 0002_artifact_intake now copies each grant on telemetry_runs (SELECT, INSERT, UPDATE and DELETE, to any role but the owner and PUBLIC) to the two intake tables, so the runtime user's access to them matches its telemetry access whether it came from explicit grants or default privileges. The README also says the service-account settings refuse the default Compute Engine account. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The contract gives every timestamp in UTC with an explicit +00:00 offset; the status document and receipt now convert to UTC rather than keeping the database session's offset. The changelog fragment now covers the scope in token responses, the intake_not_enabled answers, the disclosure rule, recovery, reprocessing and the intake tables' grants. 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 6.2 now pauses #12's queue and recovery schedule together and lists unfinished executions of both the worker and the recovery job. It gives the possible wait (three attempts of up to 1 hour each), how to cancel a worker execution and what recovery then does with its job, and why the queue must never be paused while recovery runs. The intake bucket stays in the old project. Each accepted submission records its files' Cloud Storage generation numbers, and the worker reads every file at that generation; a copy into a new bucket gets new generation numbers, so the copy step is removed. Uploads still in flight need nothing, because the bucket doesn't change. Step 6.5 and section 12 now point the queue, worker job, signing account and invoker account settings at the new project. A new step 6.7 creates the recovery schedule only after the deployment. 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
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
If #12's intake is switched on before the move, its bucket stays in policyengine-api, so its storage cost, data-access logs and administration stay there, and shutting that project down or removing its billing would make every submission's files unreadable. The memo's D2 B row, "What a dedicated project gives" and "Order and timing" now say so, recommend switching intake on only after the move, and record that intake is off today (read-only check: the Cloud Tasks and Cloud Scheduler APIs aren't enabled, and no intake bucket exists). In the runbook, step 3.2 also records the old accounts' project-level roles, because #12's unwritten deployment wiring may grant storage access on the project. Step 5.3 names the permissions the code needs on the bucket (create and list for the runtime account, get for the worker's) and grants them on the bucket. Moving the bucket later is a one-off data change that rewrites the recorded generation numbers, not necessarily a code change. Section 9 checks the bucket's policy and one test submission, and section 11 says the old project must stay active while the collector reads the files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
that referenced
this pull request
Oct 20, 2026
Step 6.2 said every reprocess run is a recovery-job execution, so the loop covers it. #12's README also allows running reprocess locally through the Cloud SQL Auth Proxy, which writes to the database without a Cloud Run execution. The step now says so and asks that nobody runs it that way during the window. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
anth-volk
added a commit
that referenced
this pull request
Oct 20, 2026
Section 12's closing paragraph now lists the open questions plainly: whether postgres may ALTER ROLE a user Cloud SQL made (step 5.2's staging check settles it); who owns a database gcloud sql databases create makes, and the old databases' owners and grants; whether either project is inside a VPC Service Controls perimeter; whether policyengine-api has a project lien; and #12's unwritten deployment wiring. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README said deploying this code changes nothing. A deployment applies migration 0002_artifact_intake and adds `scope` to token responses, and an image without that migration then fails its migration job and its /ready check until the database is downgraded to 0001_initial_schema. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nges A re-review found that the downgrade must run with this release's code and as the migration database user (the older code can't find revision 0002 and the runtime user doesn't own the intake tables), and that a deployment also changes the token exchange's answers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 26, 2026
Brings in #6 (the submission contract, released as 0.4.0), #4, #11 and #23. No conflicts: README.md, pyproject.toml and uv.lock merged automatically, and the consumed fragment changelog.d/artifact-submission-contract.added.md is removed, leaving only this branch's artifact-intake.added.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Replace the test of copying telemetry_runs' grants with three tests of upgrading to 0002_artifact_intake as the deployments' migration user does: the tables belong to a migration role, the telemetry tables are granted by hand, and the migration role's default privileges grant every table it creates in public to the runtime role. - The migration issues no GRANT. - The runtime role has SELECT, INSERT, UPDATE and DELETE on both intake tables, and a role with no grants has none. - A role granted telemetry_runs, but not named in the default privileges, gets no access to the intake tables. Against the current migration the first and third fail, because it copies telemetry_runs' grants; the second passes, as default privileges apply whatever the migration does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Migration 0002_artifact_intake no longer reads telemetry_runs' grants from the catalog and repeats them on artifact_submissions and artifact_jobs. That copy was implicit, gave the intake tables to every role with access to telemetry_runs, and was skipped when SQL was generated offline. The migration now grants nothing. The runtime database user's access comes from the migration user's default privileges, set once by hand in each environment before this migration is applied. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's "Database access" now says the migrations grant nothing, and gives the one-time statement that sets the migration user's default privileges, with its roles named by the secrets they come from: who can run it (the migration user, or a member of it with INHERIT; PostgreSQL 16 refuses a member with only ADMIN or SET), how to check it with \ddp and has_table_privilege, and that a serial column would also need sequence privileges. The changelog fragment says the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings in #25 (released as 0.4.1): the certificate test now checks the trusted roots of the connection HuggingFaceAuthenticator opens, by fingerprint, and CHANGELOG.md gains its 0.4.1 entry. No conflicts: pyproject.toml and uv.lock take 0.4.1 and keep this branch's Google dependencies (uv lock --check passes), and artifact-intake.added.md is still the only fragment, so this branch's merge releases 0.5.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README's "Database access" said each environment sets the migration user's default privileges before 0002_artifact_intake is applied. A read-only check through each environment's migration job on 2026-10-27 found that staging and production both have them, so before a merge there is only a \ddp check, and nothing to run. It now also says when the statement applies again: when the database users are recreated, as the project move in #21 does. A default-privileges entry belongs to the role, not its name, so the new database needs a \ddp check before a migration job runs there. The intake checklist now lists all four privileges the default privileges grant, not three. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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 #7
Based on
main. #6 (the submission contract) merged on 2026-10-27 (35680a2) and was released as 0.4.0, now deployed to staging and production; GitHub moved this PR's base tomainwhen #6's branch was deleted.mainis merged in again (8741383), bringing #25 (released as 0.4.1), so the diff shows only intake and this PR's merge releases 0.5.0. A merge of this PR deploys staging and production, and its migration job applies0002_artifact_intake. The intake tables get the runtime user's access from the migration user's default privileges, which both environments already have (checked 2026-10-27). Before it merges, only check each environment with\ddp: see "Default privileges: check before merging" below.Summary
Implements
docs/artifact-submissions.mdin the collector, switched off by default.POST /v1/submissionsvalidates the submission, records it under its derived ID (201 new, 200 repeat) and returns a signed upload instruction for each file not yet stored.POST /v1/submissions/{id}/completelists the submission's objects. Missing files get 409files_missing. A stored object of another size or recorded SHA-256 rejects the submission (stored_object_mismatch), because stored objects are never replaced. Otherwise it records each object's generation and the round 1 job, and dispatches it through Cloud Tasks. If Cloud Tasks is unavailable it answers 503worker_queue_unavailablewithRetry-After.GET /v1/submissions/{id}(status, with the current job) andGET /v1/submissions/{id}/receipt(the final record with each file's generation and its own SHA-256; 409submission_not_finaluntil final).codebesidedetail:submission_not_found,submission_owned_by_another_user,files_missing,submission_not_final,worker_queue_unavailable,intake_not_enabled.signBlobwith the runtime's own credentials (no key file). Each instruction lists every header the signature covers:Content-Type,Host(from the URL), the declared SHA-256 as object metadata, an exact-sizex-goog-content-length-rangeandx-goog-if-generation-match: 0. URLs are signed 16 at a time so a 256-file response stays inside the 30 s request timeout.python -m telemetry_collector.worker, a Cloud Run Job). Each dispatch is a Cloud Task named<job_id>-<dispatch number>that calls the Cloud Run Admin API to run the job withCOLLECTOR_WORKER_JOB_IDset. The worker leases the job for 65 minutes (longer than the one-hour task timeout) underCLOUD_RUN_EXECUTION, streams each file at its recorded generation, checks size and SHA-256, then calls the handler registered for the kind. Both kinds start with a handler that only records the check ({"processed_by": "intake-only", ...}). Handlers get the disclosure rule fromJobContext.output_visibility.python -m telemetry_collector.operations):recover, every 10 minutes: fails a job whose lease expired after its third attempt (attempts_exhausted) or that would need a fourth dispatch (worker_unavailable), and dispatches again a job whose attempt ended early or that stayed queued for 30 minutes.reprocess SUBMISSION_ID...orreprocess --kind <kind> --intake-only [--dry-run]: records the next processing round of final submissions and dispatches it.0002_artifact_intakeaddsartifact_submissionsandartifact_jobs(status check constraints, one job per submission and processing round, attempts, dispatch count, lease, result). The migration grants nothing: the runtime database user's access to the two tables comes from the migration user's default privileges, set once by hand per environment and already in place in staging and production (see "Default privileges: check before merging"). Downgrading drops only these two tables.IntakeObjectStore,WorkerQueue). Endpoint and worker tests use in-memory fakes; the in-memory submission store shares the PostgreSQL store's decision functions and runs the same contract tests. One adapter test signs with the real google-cloud-storage library, standing in only for the IAMsignBlobrequest.Auth: one scope per token
{"scope": "artifacts:submit"}. No body, or{}, still yields atelemetry:writetoken with the same claims as before.scopefield. A collector released before intake ignores the body and returns noscope, so a client can tell it has no intake.artifacts:submit, and telemetry endpoints still requiretelemetry:write.app.py(_issue_session_token,_decode_session_token), so whichever merges second resolves a small conflict. This PR's only change there since the last review isscope=scopein the returnedCollectorToken.Switched off by default
COLLECTOR_INTAKE_ENABLEDis exactly1. While it is off, the collector reads no other intake setting and doesn't import the Google clients, and the exchange forartifacts:submitand every/v1/submissionspath answer 404 with{"code": "intake_not_enabled"}(the paths are absent from OpenAPI).scripts/deploy.shis unchanged. Its--set-env-varssets onlyTELEMETRY_MAINTENANCE_MODE, which replaces the service's variables on every deploy, so the switch can't be on in any deployed revision.0002_artifact_intake(two empty tables, whose access comes from the default privileges), and token responses gainscope.Commits (red, then green)
Each commit's test files were run before the change (red, against stubs for names that didn't exist yet, so failures counted individually) and after it (green), then again after committing. PostgreSQL cases ran against a disposable local PostgreSQL 16 container.
Earlier commits:
Review fixes (2026-10-20):
1396a98): carries Document the artifact submission contract #6's revised contract. Before it, the intake test files passed (151).d1799f3):build_id,releaseand folder segments use the emitter'ssafe_identifierrule, repo parts are at most 96 characters, folders at most 512;files_sha256;SubmissionRecord.statusandkinduseSubmissionStatusandSubmissionKind; the status sets are defined once insubmissions.py; the intake prefix comes fromintake_object_prefix. Red 16 failed, 108 passed; green 124 passed. During green, the 512-character folder test itself built 513 characters, and was corrected. The new test that the migration's status constraints equal the models' passed in red too, as it guards an equality that already held.2866df3):scopein token responses,intake_not_enabledon the exchange and every submission path, codes on intake errors. Red 10 failed, 15 passed; green 25 passed.b5bb54f):stored_object_mismatchat completion, create signs only missing files, the status document reports the job. Red 10 failed, 49 passed; green 59 passed.ad876ef): red 2 failed, 31 passed; green 33 passed. The new test that signs with the real library passed in red as well: google-cloud-storage 3.17.1 addsContent-TypeandHostto the headers dict it is given, which leaked into the instructions unlisted and untested. The adapter now passes the library a copy and lists all five headers itself.92fa3b7): processing rounds, numbered dispatches, recovery, reprocessing, the operator commands, the 65-minute lease, the README's worker settings, and PostgreSQL tests that run completions, claims and recoveries concurrently. One test claims the job at its third attempt and ends that attempt without finishing or releasing it. Red 65 failed, 134 passed (the two concurrency tests for completions and for claims passed, as the row locks already held; the two for recoveries failed); green 210 passed.9991bd8): red 7 failed; green 26 passed.1372cab): red 1 failed, 5 passed; green 12 passed. Replaced by 19 and 20.30dbd1b): red 1 failed; green 22 passed.Update from
main(2026-10-27):e57c569): brings in Document the artifact submission contract #6 (released as 0.4.0), Refuse extraction records that squash or rebase merges dropped from main #4, Carry over calibration-diagnostics#219: apply each event batch incrementally #11 and Lock certifi 2026.10.19 to stop trusting the compromised eMudhra CA certificates #23, with no conflicts.README.md,pyproject.tomlanduv.lockmerged automatically: the README keeps both sides' sections,pyproject.tomlhas version 0.4.0 with this PR's dependencies, anduv.lockhas both this PR's Google packages and certifi 2026.10.19 from Lock certifi 2026.10.19 to stop trusting the compromised eMudhra CA certificates #23 (uv lock --checkpasses). Document the artifact submission contract #6's consumed fragmentchangelog.d/artifact-submission-contract.added.mdis removed, soartifact-intake.added.mdis the only fragment. The contract change Document the artifact submission contract #6 made after this PR's last merge of its branch (393b948: a receipt for a submission rejected at completion has nogenerationinfiles) matches the code:reject_submissionrecords no generations (checked by reading the code; no test asserts it).Default privileges instead of copied grants (2026-10-27):
b6071ed): replaces the test of copyingtelemetry_runs' grants with three tests that upgrade as a deployment does. A migration role owns the tables, the telemetry tables are granted by hand to the runtime role and to one other role, and the migration role's default privileges are set as the statement below sets them. The tests check that the migration issues noGRANT; that the runtime role hasSELECT,INSERT,UPDATEandDELETEon both intake tables and a role with no grants has none; and that the other role, grantedtelemetry_runsbut not named in the default privileges, gets nothing. Red (tests/test_migrations_postgres.py): 2 failed, 6 passed. The default-privileges test passed in red too, as PostgreSQL applies default privileges whatever the migration does; the other two failed because the migration issued 8GRANTs and gave the other role all four privileges.16f1f88): removesgrant_telemetry_access_to_intake_tables()and its catalog query. Green: 8 passed.f8b0895): the README's "Database access" gives the statement, who can run it, how to check it and when sequences would need it; the changelog fragment says the migration grants nothing.Review of commits 19 to 21, and
mainmerged in (2026-10-27):8741383): brings in Test the Hugging Face check's own trusted roots by fingerprint, and name certifi 2026.10.19 as the advisory's first fixed version #25 (released as 0.4.1):test_certificate_authorities.pynow checks the trusted roots of the connectionHuggingFaceAuthenticatoropens, by fingerprint, andCHANGELOG.mdgains its 0.4.1 entry. No conflicts.pyproject.tomlanduv.locktake version 0.4.1 and keep this PR's Google dependencies (uv lock --checkpasses), andartifact-intake.added.mdis still the only fragment, so this PR's merge releases 0.5.0.b1495a6): the README's "Database access" said each environment sets them before0002_artifact_intakeis applied. It now says staging and production both have them (checked 2026-10-27), so before a merge there is only a\ddpcheck, and that the statement applies again when Record the project and hostname decisions (D2, D3) and the runbook for the hostname (C2) and the move (C5) #21's project move recreates the database users. The intake checklist now lists all four privileges the default privileges grant. Documentation only.Testing
b1495a6), on Python 3.13 withTEST_DATABASE_URLpointing at a disposable local PostgreSQL 16.15: the test files for what changed (test_migrations_postgres.py,test_migrate.py,test_database.py,main'stest_certificate_authorities.py,test_auth.py, the changelog'stest_release_versioning.py, the lock file'stest_packaging.py, the README'stest_quality_check.py,test_extraction.pyand the vocabulary test'stest_deployment_workflow.py), together: 75 passed, none skipped. The review also rerantest_migrations_postgres.pyagainst commit 19's migration: 2 failed (test_artifact_intake_migration_issues_no_grant,test_artifact_intake_gives_telemetry_grantees_no_intake_access), 6 passed; and atf8b0895: 8 passed.python -m telemetry_collector.migratethenalembic checkreports "No new upgrade operations detected.";alembic upgrade 0001_initial_schema:0002_artifact_intake --sqlprints noGRANT;tools/check-quality.sh(Ruff check, format check,bash -n) passes;uv lock --checkpasses;tools/check-changelog.shreports 0.4.1 to 0.5.0.f8b0895), on Python 3.13 withTEST_DATABASE_URLpointing at a disposable local PostgreSQL 16.15: the test files for what changed (test_migrations_postgres.py,test_migrate.py,test_database.py, the vocabulary test'stest_deployment_workflow.py, the README'stest_quality_check.py, the changelog'stest_release_versioning.py), together: 53 passed.alembic upgrade 0001_initial_schema:0002_artifact_intake --sqlprints noGRANT;python -m telemetry_collector.migratethenalembic checkreports "No new upgrade operations detected.";tools/check-quality.shpasses;tools/check-changelog.shreports 0.4.0 to 0.5.0 (the branch doesn't contain Test the Hugging Face check's own trusted roots by fingerprint, and name certifi 2026.10.19 as the advisory's first fixed version #25 yet, which released 0.4.1). CI passes onf8b0895.main(e57c569), on Python 3.13 withTEST_DATABASE_URLpointing at a disposable local PostgreSQL 16: this PR's 11 test files, the test files for whatmainchanged or what both sides changed (test_app.py,test_storage.py,test_storage_postgres.py,test_incremental_batch_apply.py,test_certificate_authorities.py,test_packaging.py,test_release_versioning.py,test_quality_check.py) andtest_deployment_workflow.py, together: 337 passed, none skipped.python -m telemetry_collector.migrateon an empty database reaches0002_artifact_intake, andalembic checkreports "No new upgrade operations detected."tools/check-quality.shpasses, andtools/check-changelog.shreports 0.4.0 to 0.5.0: this PR's oneaddedfragment raises the minor version, so its merge releases 0.5.0. The repository defines no type checker.TEST_DATABASE_URLpointing at a disposable local PostgreSQL 16: 241 passed.python -m telemetry_collector.migratethenalembic checkon that database: "No new upgrade operations detected."tools/check-quality.sh(Ruff check and format check,bash -non scripts) passes.tools/check-changelog.shpassed then too (0.3.0 to 0.4.0, while Document the artifact submission contract #6's fragment was still pending).test_app.pyand the vocabulary test intest_deployment_workflow.py, althoughapp.pychanged. CI covers them.Deployment verification
This PR changes no workflow, Dockerfile or deploy script (checked against
mainafter the merge).uv.lockgains google-cloud-storage, google-api-core, google-auth and requests (no gRPC), which the existing Docker build installs.COLLECTOR_INTAKE_ENABLEDdeploy.sh, so off in every revision (checked indeploy_maintenanceanddeploy_candidate).COLLECTOR_INTAKE_BUCKETstaging/productionenvironment variable passed bydeploy.sh, and set on the worker job.COLLECTOR_INTAKE_SIGNING_SERVICE_ACCOUNTTELEMETRY_RUNTIME_SERVICE_ACCOUNTvalue<name>@<project>.iam.gserviceaccount.com) and refuses the default Compute Engine account. I couldn't confirm which accountTELEMETRY_RUNTIME_SERVICE_ACCOUNTnames: reading the environment variable was refused.COLLECTOR_WORKER_QUEUEdeploy.shto the service and set on the recovery job.COLLECTOR_WORKER_JOBCOLLECTOR_WORKER_INVOKER_SERVICE_ACCOUNTCOLLECTOR_WORKER_JOB_IDjobs.runenvironment overrideDATABASE_URLTELEMETRY_DATABASE_SECRET_NAME)--set-secrets.CLOUD_RUN_EXECUTIONOn 2026-10-27 the GitHub
stagingandproductionenvironments still had noCOLLECTOR_INTAKE_*orCOLLECTOR_WORKER_*variable (only theTELEMETRY_*onespush.yamlpasses todeploy.sh), anddeploy.shonmainstill passes onlyTELEMETRY_MAINTENANCE_MODEwith--set-env-varsin bothdeploy_maintenanceanddeploy_candidate. None of these is a secret value. The switch-on PR adds the deployment wiring:deploy.shpassing the five service settings withCOLLECTOR_INTAKE_ENABLED=1, and updating both jobs' images each release, as it does for the migration job.Database connections. The migration job connects with the migration database secret, so its user owns the tables it creates; the service, worker and operator commands connect as the runtime user. The migration grants nothing; the runtime user's access to the intake tables comes from the migration user's default privileges, which both environments already have (see "Default privileges: check before merging").
DATABASE_URLin the migration jobtelemetry_collector.migrate.main,migrations/env.pystaging/productionenvironment variableTELEMETRY_MIGRATION_DATABASE_SECRET_NAME→push.yamljobenvMIGRATION_DATABASE_SECRET_NAME→deploy.sh configure_migration_job,--set-secrets DATABASE_URL=<secret>:lateston the job, which runs asTELEMETRY_MIGRATION_SERVICE_ACCOUNTwith the Cloud SQL instanceTELEMETRY_CLOUD_SQL_CONNECTION40506286831) passed "Check required Secret Manager entries exist", "Configure the database migration job" and "Apply database migrations" in staging and production, so the secret exists, the job's account can read it, and its user can connect and migrate.DATABASE_URLin the servicecreate_app_from_environment(app.py)TELEMETRY_DATABASE_SECRET_NAME→DATABASE_SECRET_NAME→deploy.sh deploy_maintenanceanddeploy_candidate,--set-secrets DATABASE_URL=<secret>:lateston the service, which runs asTELEMETRY_RUNTIME_SERVICE_ACCOUNT/ready, which connects to the database and reads the Alembic revision.DATABASE_URLin the worker and recovery jobsworker.py,operations.py<migration_role>and<runtime_role><migration user>grants<runtime user>arwdon new tables inpublic.Default privileges: check before merging (per environment)
Default privileges apply only to tables created after they are set, and this PR's merge applies
0002_artifact_intakein staging and then production. Both environments already have them (see "Status"), so before this PR merges, only check each one with\ddp; there is nothing to run. Where an entry is missing, this sets it:The roles:
<migration_role>is the database user in the migration database secret, the Secret Manager secret named by the environment variableTELEMETRY_MIGRATION_DATABASE_SECRET_NAME, which the migration job mounts asDATABASE_URL.<runtime_role>is the user in the runtime database secret, named byTELEMETRY_DATABASE_SECRET_NAME, which the service mounts asDATABASE_URL.Status: staging and production both have it. A read-only run of each environment's migration job on 2026-10-27 found that
<migration user>, which owns all four tables inpublic, grants<runtime user>SELECT,INSERT,UPDATEandDELETEon new tables inpublic({<runtime user>=arwd/<migration user>}), and that neither database has any sequences. An older third database user in production had no grants ontelemetry_runsand no connections, so the grant copying this PR removed would have given it nothing either; it was deleted from production later on 2026-10-27. Check each environment with\ddpbefore this PR merges; run the statement only where the entry is missing (running it again changes nothing).When it applies again: Record the project and hostname decisions (D2, D3) and the runbook for the hostname (C2) and the move (C5) #21's project move recreates the database users. A default-privileges entry belongs to the role itself, not its name, so a recreated
<migration user>has one only if the import or the statement creates it. A dump carries the entry as anALTER DEFAULT PRIVILEGES FOR ROLEstatement, and if the import runs as a user withoutINHERITmembership in the migration user, PostgreSQL 16 refuses it (see "Who can run it"), so the entry can be missing; how Cloud SQL's import treats that error isn't verified. Check\ddpin each new database before a migration job runs there (Record the project and hostname decisions (D2, D3) and the runbook for the hostname (C2) and the move (C5) #21's step 6.4 compares it with the step 3.2 record), and run the statement if the entry is missing.Who can run it: connected to the collector's database as the migration user itself, or as a member of it with the
INHERIToption. On Cloud SQL that is typicallypostgres, if it is such a member. PostgreSQL 16 refuses the statement ("permission denied to change default privileges") for a member with onlyADMINorSET, andpostgresholds a role it created in SQL with onlyADMIN. In that case either connect as the migration user, or havepostgresrunGRANT <migration_role> TO postgres WITH INHERIT TRUE;first andREVOKE <migration_role> FROM postgres;after, which leaves its originalADMINmembership.postgresisn't a member of a user thatgcloud sql users createmade, so for that user connect as the migration user. (All checked on PostgreSQL 16.15. The review confirmed the rule in PostgreSQL 16's source:ExecAlterDefaultPrivilegesStmtrequireshas_privs_of_role, which follows only memberships withINHERIT, since commit48a257d"Make ALTER DEFAULT PRIVILEGES require privileges, not membership"; PostgreSQL 15 required membership only. PostgreSQL 16's "Role Attributes" page says a role made by aCREATEROLEuser that isn't a superuser is granted back to it withADMINbut "INHERIT FALSE, SET FALSE", and that the creator "can gain access to the created role by simply granting that role back to themselves with theINHERITand/orSEToptions".)How to check it: before the merge,
\ddpshows a row with owner<migration_role>, schemapublic, typetable, and access privileges<runtime_role>=arwd/<migration_role>. After each environment's migration, every row of this is true:If the migration runs first: the two tables exist without the runtime user's access. Nothing reads them while intake is off. Grant them once, as the migration user, with
GRANT SELECT, INSERT, UPDATE, DELETE ON artifact_submissions, artifact_jobs TO <runtime_role>;, then set the default privileges for later tables.Sequences: neither migration has a
serialor identity column (checked on a database migrated to head: no sequences, identity columns ornextvaldefaults), and neither staging nor production has any sequences (checked 2026-10-27), soUSAGE, SELECT ON SEQUENCESisn't needed. A later migration that adds aserialcolumn would need it: on PostgreSQL 16, the runtime user's insert into aserialcolumn is refused withoutUSAGEon its sequence, while an identity column needs no sequence privilege.The telemetry tables: their grants to the runtime user were made by hand when each database was set up, outside this repo; collector#21's runbook records them (
\dp public.*and\ddp, step 3.2).Manual infrastructure steps (not done; run per environment, staging first)
The repo keeps no infrastructure templates, so these are manual.
PROJECT,REGION,CLOUD_SQL_CONNECTION,RUNTIME_SAandDATABASE_SECRETare the existingTELEMETRY_*environment values.Which project these steps use depends on decision D2. Taking
PROJECTfrom the existingTELEMETRY_*values puts the bucket, queue and jobs in today's shared project. If intake is switched on before the move, collector#21's runbook keeps the bucket inthe current project. A copy would get new generation numbers, and the worker rejects copied files withobject_missingthe next time it processes them, for example on reprocessing. If D2 is B or C, the memo recommends switching intake on only after the move. The code reads every resource name from the environment, so only the order of these steps and the project move matters.Then create the GitHub
stagingandproductionenvironment variables for the switch-on PR: the bucket, queue, worker job, recovery job and invoker account names, and the worker and recovery service accounts.Not verified against Google Cloud
No real bucket, queue, job or schedule exists, so these need checking on staging before switching on:
x-goog-content-length-rangeandx-goog-if-generation-match: 0as documented, and what status an expired URL gets;jobs.runapplies acontainerOverridesentry without a containernameto the job's only container;--max-retries 2gives the three attempts recovery counts on;jobs.run, and the exact role names above, especiallyroles/run.jobsExecutorWithOverridesandroles/run.invokeron a job;has_table_privilegequery in "Default privileges: check before merging".Review round 2026-10-22
A re-review found no code bugs and confirmed every 2026-10-20 finding fixed. It found three documentation problems, now fixed:
d6aa908): the README said deploying this code changes nothing. A deployment applies migration0002_artifact_intakeand addsscopeto token responses. After that migration, an image without it (an earlier collector release, or thetelemetry-service/copy in calibration-diagnostics) fails its migration job ("Can't locate revision identified by '0002_artifact_intake'") and its/readycheck, so calibration-diagnostics' manual redeploy would stop at its staging migration. Rolling back first needsalembic downgrade 0001_initial_schema, run with this release's code and as the migration database user, which drops only the two intake tables (checked locally). The README now says so (d6aa908,8357b0d), and that the token exchange's answers change too.Review round 2026-10-27
A review of commits 19 to 21 found no code bugs. This description and the README were out of date with the 2026-10-27 production check, and are now corrected.
f8b0895all eight pass. The statement recorder also seesCREATE TABLE artifact_jobs, so an empty recording can't pass the no-GRANTtest. Its pattern matches only statements that begin withGRANT, and it records only what runs after0001_initial_schema(which has no grants). The test of the role granted onlytelemetry_runschecks the resulting privileges, not the SQL, so it catches copying by any statement.deploy.shchecks that the secrets exist and that/readypasses, and/readyreads onlyalembic_version; no code, workflow or test checks table privileges.docs/artifact-submissions.mdandCHANGELOG.mddon't mention them. The intake code issuesSELECT,INSERT,UPDATEandSELECT ... FOR UPDATE, neverDELETE.INHERITclaim is right (sources under "Who can run it"). A throwaway check on PostgreSQL 16.15 also showed that a member withADMINonly, orSETonly, is refused, a self-grantWITH INHERIT TRUEis accepted, and theREVOKEleaves the originalADMINmembership.\ddpcheck is needed; both texts now also say the statement applies again when Record the project and hostname decisions (D2, D3) and the runbook for the hostname (C2) and the move (C5) #21's project move recreates the database users (b1495a6).mainmerged in (8741383) with no conflicts; this PR's merge releases 0.5.0.🤖 Generated with Claude Code