From 0ea7bc2c51efbcc62820c0ef222366ccc9aab145 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 18 Oct 2026 01:45:16 -0500 Subject: [PATCH 1/2] chore: record owner decisions from the editor and renderer transfer PR #99 merged before three owner decisions about the 2026-10-17 transfer of atrinik/editor and atrinik/renderer could be recorded. Record them: - Accept and record the transfer-window exposure of the crate dependency App key. A new validated `accepted_key_exposures` field records the retained copy in seraphinastudios/tilewright-editor (ID 1327106902), its 2026-10-17 start, the 2026-10-18 names-only removal readback and the owner's 2026-10-18 acceptance. Validation binds each exposure to the current key and a closed window. The rotate-by deadline stays 2027-01-09. - Mark the App's client-only selected set as pending re-verification. A new validated `repository_scope_reverification` state records that the 2026-10-17 transfer removed atrinik/editor after the 2026-10-11 readback. bin/verify-manual-settings reports it as PENDING instead of the ordinary MANUAL reminder. - Record in the README that the owner is revoking the atrinik/renderer Trusted Publisher from its seven crates.io crates, pending confirmation, and name the renderer crates directly where the text pointed at a table that no longer lists them. Tests cover both new schema states with negative cases, the verifier's PENDING and MANUAL output, and planned-state resets. Pinned validation dates move to 2026-10-18 so the new observations are not in the future. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 5 +- CONTRIBUTING.md | 5 +- README.md | 130 +++++++++++++++++---- bin/validate | 60 +++++++++- bin/verify-manual-settings | 11 +- config/manual-settings.json | 30 +++++ tests/check-project-health.sh | 6 +- tests/validate-crate-trusted-publishing.sh | 86 +++++++++++++- tests/validate-manual-credentials.sh | 7 +- tests/validate-planning-health.sh | 4 +- tests/verify-manual-settings.sh | 27 ++++- 11 files changed, 326 insertions(+), 45 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 5150f6b..925f541 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -173,7 +173,10 @@ consumer paths, verified key creation date, verification date, rotation owner, and a rotate-by deadline within the existing 90-day cadence. The deadline is internal policy, not upstream expiry; verify replacement workflow use before - revoking the old key. + revoking the old key. Keep a selected-set change after the recorded readback + as pending `repository_scope_reverification` until fresh readback replaces + it. An owner-accepted key exposure binds to the current key and a closed + window, never extends rotate-by, and is removed on rotation. - Record GitHub Pages sites with stable repository identity, exact provider URL, HTTPS enforcement, owning deployment environment, desired Actions source, workflow path, and immutable deployment-action marker. Before that marker is diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1776b5a..2d51855 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -251,7 +251,10 @@ creation/verification dates, rotation owner, and a deadline within 90 days of ke creation; that internal deadline does not assert upstream expiry. Verify a replacement key in the owning writing workflow before revoking the old key. Keep App-authenticated selected-set readback or owner -UI evidence, and do not infer the webhook switch from an empty event set. +UI evidence, and do not infer the webhook switch from an empty event set. A +selected-set change after that readback stays pending re-verification until +fresh evidence is recorded. Owner-accepted key exposures bind to the current key +and a closed window and never extend the rotate-by deadline. Private linux-build consumption needs separate client-only read grants through owner UI. Keep exact private package/source identities and pending grant state until observed; metadata and owner pulls do not prove an effective owning diff --git a/README.md b/README.md index 74a20ec..a05ed10 100644 --- a/README.md +++ b/README.md @@ -1589,9 +1589,13 @@ reviewed source workflows produced the signed immutable releases used for the initial library publications. Protocol registration was owner-observed on 2026-10-11 and its manual 0.2 upload succeeded in [run 38156755961](https://github.com/atrinik/protocol/actions/runs/38156755961). -The fourteen renderer/toolkit names have now been bootstrapped and their exact -Trusted Publisher registrations are owner-observed. Their automatic jobs passed -OIDC authentication and reconciliation of the existing published coordinates; +The fourteen initial renderer/toolkit libraries have been bootstrapped and their +exact Trusted Publisher registrations are owner-observed: the seven +content-toolkit libraries in the table below, and the seven renderer libraries +that were registered for `atrinik/renderer` before it left the organization +(see [Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)). +Their automatic jobs passed OIDC authentication and reconciliation of the +existing published coordinates; [the onboarding evidence below](#initial-library-onboarding-evidence) records that scope. A source inventory update records these observations; it does not perform external registration or publication. @@ -1651,12 +1655,15 @@ are the supported provisioning references. Activation proceeds in this order: 2. Verify live repository ID/name, active status, default branch, exact main ref, protected main, and both merged publication/source-signing workflows. Record `source_state: observed-reviewed-workflow` only after that observation. -3. The fourteen renderer and toolkit names above require a first owner-local - publication because Trusted Publishing cannot create their initial crate - records. Prepare, review, and verify the exact release archives, package - dependency order, versions, digests, native prerequisites, and registry - readiness first. The owner uses a narrowly scoped local API token only for - that initial bootstrap, then revokes it. Exclude the renderer CLI +3. The fourteen initial renderer and toolkit names (the toolkit libraries in + the table above and the seven renderer libraries listed under + [Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)) + require a first owner-local publication because Trusted Publishing cannot + create their initial crate records. Prepare, review, and verify the exact + release archives, package dependency order, versions, digests, native + prerequisites, and registry readiness first. The owner uses a narrowly + scoped local API token only for that initial bootstrap, then revokes it. + Exclude the renderer CLI `atrinik-render` and toolkit applications `atrinik-content` and `atrinik-content-mcp`. Protocol already exists and needs no bootstrap. No bootstrap token is sent to GitHub or stored in this inventory. @@ -1676,9 +1683,15 @@ are the supported provisioning references. Activation proceeds in this order: ### Initial library onboarding evidence On 2026-10-12 (UTC), the owner confirmed Trusted Publisher registration for all -fourteen renderer/toolkit names in the table above, with their respective exact -repository, `publish-crate.yml` and `crates-io-release` environment. Registration -is external owner evidence; the GitHub metadata API does not enumerate it. +fourteen renderer/toolkit names, with their respective exact repository, +`publish-crate.yml` and `crates-io-release` environment: the seven +`atrinik/content-toolkit` libraries in the table above, and the seven +`atrinik/renderer` (`1327106998`) libraries `atrinik-render-api`, +`atrinik-render-resources`, `atrinik-render-sdl3`, `atrinik-render-testkit`, +`atrinik-render-ui`, `atrinik-render-wgpu` and `atrinik-scene`. The table no +longer lists the renderer because it left the organization on 2026-10-17. +Registration is external owner evidence; the GitHub metadata API does not +enumerate it. The initial renderer libraries are bound to the signed immutable [v1.8.0 source release](https://github.com/atrinik/renderer/releases/tag/v1.8.0) @@ -1762,6 +1775,29 @@ remaining registered contract needs them. Preserve published crate versions, release assets, tags, unrelated environments, and unrelated Actions access. Removing unactivated inventory intent is also a valid reviewed rollback. +### Renderer Trusted Publisher revocation + +The seven renderer libraries `atrinik-render-api`, `atrinik-render-resources`, +`atrinik-render-sdl3`, `atrinik-render-testkit`, `atrinik-render-ui`, +`atrinik-render-wgpu` and `atrinik-scene` are still registered on crates.io with +a Trusted Publisher for `atrinik/renderer`, `publish-crate.yml` and +`crates-io-release`, owner-observed on 2026-10-12. `atrinik/renderer` +(`1327106998`) became `seraphinastudios/tilewright-renderer` on 2026-10-17 (see +[Transferred repositories](#transferred-repositories)), so no contract in this +repository covers those registrations any more. The former name can be reused: +creating a new `atrinik/renderer` in the organization ends GitHub's redirect to +the transferred repository. A new repository with that name, a matching +`publish-crate.yml` workflow and a `crates-io-release` environment would then +match the registrations and could publish new versions of those crates. + +On 2026-10-18 the owner decided to revoke the `atrinik/renderer` Trusted +Publisher from all seven crates through the crates.io owner UI, the first step +of the rollback above. **The revocation is an owner action pending +confirmation.** It has not been observed yet, so treat all seven registrations +as live until it is. After the owner confirms it, record the dated, sanitized +owner observation for each crate here through reviewed source. Revoking a +Trusted Publisher does not yank or delete any published version. + ## Protocol crate Trusted Publishing The automatic contract above replaces the former planned per-release @@ -1770,7 +1806,10 @@ future reviewed source/version merges use the automatic path after observed onboarding. Protocol registration is owner-observed with the successful manual 0.2 run; its automatic source/environment setup is observed. Renderer/toolkit initial publication and owner registration are complete, with the known-coordinate OIDC -proofs above. The dependency App's workflow activation is pending: its only +proofs above. The renderer's seven registrations now await the owner's +revocation (see +[Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)). +The dependency App's workflow activation is pending: its only remaining consumer, the client, has not yet run its PR-writing job. ## Crate dependency update App @@ -1789,13 +1828,27 @@ signed commit, all ordinary checks passed, independent review accepted it, and the maintainer merged it through the ordinary process. That observation belongs to the transferred repository and does not count as client activation. The live App is `atrinik-crate-dependency-update`, App ID `5276152`, installation -ID `170332148`, with **selected repositories** exactly `atrinik/client` (ID -`1327106844`). The editor repository left the organization on 2026-10-17 (see -[Transferred repositories](#transferred-repositories)); the editor's copy of the -App credential and variable were deleted from the transferred repository. App-authenticated supported -API readback confirmed that complete selected set, exact contents write/pull -requests write/metadata read permissions, no subscribed events, and the external -homepage `https://www.atrinik.org`. Preserve every other App and installation, +ID `170332148`. Its expected **selected repositories** set is exactly +`atrinik/client` (ID `1327106844`). App-authenticated supported API readback on +2026-10-11 confirmed the selected set at that time, `atrinik/client` and +`atrinik/editor` (ID `1327106902`), exact contents write/pull requests +write/metadata read permissions, no subscribed events, and the external homepage +`https://www.atrinik.org`. The editor left the organization on 2026-10-17 (see +[Transferred repositories](#transferred-repositories)), which changed the +installation's repository set after that readback. The client-only set is +therefore recorded as pending re-verification: `repository_scope_reverification` +has state `pending-reverification`, reason `repository-transfer-after-readback`, +the 2026-10-11 readback date, the 2026-10-17 change date and the removed editor +identity. No readback of the client-only set has been recorded yet. + +To re-verify, the owner confirms in the installation's organization-owner UI, +or through supported App-authenticated readback, that the selected set is +exactly `atrinik/client`. Reviewed source then records that method in +`repository_scope_verification`, the readback date in `observed_on`, and sets +`repository_scope_reverification` to `null`. Validation accepts a pending record +only for an owner-observed App, only when its readback date equals `observed_on` +and the change came after it, and only for removed organization repositories +that are no longer selected. Preserve every other App and installation, including the existing Classic updater and provider Apps. The desired disabled webhook is a separate owner UI setting; `events: []` proves no subscriptions and does not independently prove the webhook switch is disabled. @@ -1823,11 +1876,31 @@ through reviewed source. If replacement proof fails, stop the client's writing workflow and preserve the old working key until the owner restores service or explicitly revokes it. +The editor's transfer also carried a copy of the current key. On 2026-10-17 +`atrinik/editor` became `seraphinastudios/tilewright-editor` (same ID +`1327106902`). GitHub keeps repository secrets and variables with a transferred +repository, so the editor's `CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY` secret and +`CRATE_DEPENDENCY_UPDATE_APP_ID` variable moved with it. Only the Atrinik owner +controls that repository and its organization, and the copy was deleted within +hours: a names-only readback on 2026-10-18 listed no Actions secrets or +variables in the transferred repository. **On 2026-10-18 the owner explicitly +accepted that exposure window instead of rotating the key early.** +`accepted_key_exposures` records the acceptance: the exposure type, the exposed +names, the former and current repository identity, the exposure start, the +removal readback and the acceptance date. The key keeps its ordinary 2027-01-09 +rotate-by deadline; the acceptance does not extend it. Validation binds each +accepted exposure to the current key and to a closed window: the exposure cannot +start before `key_created_on`, its removal must be observed, and acceptance +cannot precede that observation. Rotating the key therefore retires the record; +remove it in the same reviewed change that records the new key's dates. + The read-only verifier checks live installation identity, exact permissions, empty events, active state, selected repository identities, and credential **names**. An administrative-token API cannot enumerate the complete selected repository set; retain current supported App-authenticated readback or owner UI -proof. It reports `credential_state: observed-name-metadata` separately from +proof. While `repository_scope_reverification` is pending, the verifier reports +`PENDING` with the recorded change instead of the ordinary `MANUAL` reminder. +It reports `credential_state: observed-name-metadata` separately from workflow activation. The client's reviewed `released-dependencies.yml` workflow has merged. The dated editor run/PR above is historical evidence of effective App use by the transferred repository's writer and does not prove the client's @@ -1865,4 +1938,17 @@ Trusted Publishing, package-access and crate dependency App inventories. The organization rulesets that named them stop matching anything; an authorized `bin/publish --apply` removes the obsolete `03 - Required CI` rulesets. Atrinik consumes both projects as external MIT dependencies. Historical evidence in the -sections above that names them is preserved unchanged. +sections above that names them is preserved. Where that evidence pointed at a +table row that left with the renderer, it now names the renderer crates +directly. + +Three owner decisions from 2026-10-18 follow up on the transfer: + +- The editor's retained copy of the crate dependency App key is an accepted + exposure, and the key keeps its 2027-01-09 rotate-by deadline (see + [Crate dependency update App](#crate-dependency-update-app)). +- The App's client-only selected set is pending re-verification, because the + transfer changed it after the 2026-10-11 readback (same section). +- The owner is revoking the renderer's crates.io Trusted Publishers; that + revocation is pending confirmation (see + [Renderer Trusted Publisher revocation](#renderer-trusted-publisher-revocation)). diff --git a/bin/validate b/bin/validate index 64be716..fe21bbf 100755 --- a/bin/validate +++ b/bin/validate @@ -981,13 +981,20 @@ jq -e --arg today "${validation_today}" ' def observed_day: (type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and (try ((. + "T00:00:00Z" | fromdateiso8601 | strftime("%Y-%m-%d")) == . and . <= $today) catch false); + def positive_id: type == "number" and floor == . and . > 0; + def repository_name: type == "string" and test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"); + def organization_repository: repository_name and startswith("atrinik/"); + def evidence_reference: type == "string" and test("^[A-Za-z0-9_./#-]+$"); .github_actions_apps as $apps | .external_provider_apps as $external_apps | .crate_dependency_update_app as $app | - ($app | keys == ["app_id", "app_slug", "consumers", "credential_state", "events", "evidence_location", "homepage_url", "installation_id", - "key_created_on", "last_verified_on", "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", - "repository_scope_verification", "repository_selection", "rotate_by", "rotation_cadence_days", "rotation_owner", "runbook", "secret_name", - "secret_scope", "status", "variable_name", "variable_scope", "webhook_active", "workflow_activation", "workflow_filename", "workflow_triggers"]) and + ($app | keys == ["accepted_key_exposures", "app_id", "app_slug", "consumers", "credential_state", "events", "evidence_location", + "homepage_url", "installation_id", "key_created_on", "last_verified_on", "observed_on", "owner", "permissions", "pull_request_policy", + "readiness", "repositories", "repository_scope_reverification", "repository_scope_verification", "repository_selection", "rotate_by", + "rotation_cadence_days", "rotation_owner", "runbook", "secret_name", "secret_scope", "status", "variable_name", "variable_scope", + "webhook_active", "workflow_activation", "workflow_filename", "workflow_triggers"]) and + ($app.accepted_key_exposures | type == "array") and + ($app.repository_scope_reverification == null or ($app.repository_scope_reverification | type == "object")) and $app.app_slug == "atrinik-crate-dependency-update" and $app.homepage_url == "https://www.atrinik.org" and $app.webhook_active == false and $app.repository_selection == "selected" and @@ -1018,11 +1025,54 @@ jq -e --arg today "${validation_today}" ' $app.app_id == null and $app.installation_id == null and $app.repository_scope_verification == "pending-owner-ui" and $app.observed_on == null and $app.evidence_location == null and - $app.key_created_on == null and $app.last_verified_on == null and $app.rotate_by == null + $app.key_created_on == null and $app.last_verified_on == null and $app.rotate_by == null and + $app.accepted_key_exposures == [] and $app.repository_scope_reverification == null elif $app.status == "owner-observed" then ($app.app_id | type == "number" and floor == . and . > 0) and ($app.installation_id | type == "number" and floor == . and . > 0) and ($app.repository_scope_verification | IN("organization_owner_ui", "app_authenticated_repository_api")) and + # A selected-set change after the recorded readback leaves the expected set + # pending re-verification until fresh owner UI or App-authenticated proof. + ($app.repository_scope_reverification == null or ( + $app.repository_scope_reverification as $pending | + ($pending | keys == ["evidence_location", "last_readback_on", "reason", "removed_repositories", + "selection_changed_on", "state"]) and + $pending.state == "pending-reverification" and + $pending.reason == "repository-transfer-after-readback" and + ($pending.last_readback_on | observed_day) and $pending.last_readback_on == $app.observed_on and + ($pending.selection_changed_on | observed_day) and + $pending.selection_changed_on > $pending.last_readback_on and + ($pending.removed_repositories | type == "array" and length > 0) and + all($pending.removed_repositories[]; + . as $removed | + (type == "object") and (keys == ["repository", "repository_id"]) and + (.repository | organization_repository) and (.repository_id | positive_id) and + all($app.repositories[]; + .repository != $removed.repository and .repository_id != $removed.repository_id)) and + ($pending.removed_repositories | map(.repository_id) | length == (unique | length)) and + ($pending.evidence_location | evidence_reference) + )) and + # An owner-accepted exposure applies only to the current key and closed + # windows; it never extends the rotate-by deadline. + all($app.accepted_key_exposures[]; + . as $exposure | + (type == "object") and + (keys == ["accepted_by", "accepted_on", "evidence_location", "exposed_names", "exposure", "exposure_started_on", + "former_repository", "removal_observed_on", "repository", "repository_id"]) and + .exposure == "repository-transfer-retained-copy" and + (.exposed_names | type == "array" and length > 0 and . == unique and + any(.[]; . == $app.secret_name) and all(.[]; . == $app.secret_name or . == $app.variable_name)) and + (.former_repository | organization_repository) and + (.repository | repository_name and (ascii_downcase | startswith("atrinik/") | not)) and + (.repository_id | positive_id) and + all($app.repositories[]; + .repository != $exposure.former_repository and .repository_id != $exposure.repository_id) and + (.exposure_started_on | observed_day) and .exposure_started_on >= $app.key_created_on and + (.removal_observed_on | observed_day) and .removal_observed_on >= .exposure_started_on and + (.accepted_on | observed_day) and .accepted_on >= .removal_observed_on and + .accepted_by == $app.owner and + (.evidence_location | evidence_reference)) and + ($app.accepted_key_exposures | length == (unique | length)) and ($app.observed_on | observed_day) and ($app.key_created_on | observed_day) and ($app.last_verified_on | observed_day) and $app.last_verified_on >= $app.key_created_on and diff --git a/bin/verify-manual-settings b/bin/verify-manual-settings index 4143129..796988f 100755 --- a/bin/verify-manual-settings +++ b/bin/verify-manual-settings @@ -934,7 +934,16 @@ done < <(jq -c '.repositories[]' <<<"${app_record}") if [[ $(jq -r '.status' <<<"${app_record}") == planned ]]; then echo "PENDING ${app_slug} selected client App installation and repository credential slots; no provisioning applied" else - echo "MANUAL ${app_slug} selected repositories require current owner UI or App-authenticated API proof: atrinik/client only" + # A selected-set change after the recorded readback stays pending until a + # fresh owner UI or App-authenticated readback is recorded. + if [[ $(jq -c '.repository_scope_reverification' <<<"${app_record}") != null ]]; then + echo "PENDING ${app_slug} selected repositories await re-verification: $(jq -r ' + .repository_scope_reverification | + "a \(.selection_changed_on) repository transfer removed \(.removed_repositories | map(.repository) | join(", ")) after the \(.last_readback_on) readback" + ' <<<"${app_record}"); fresh owner UI or App-authenticated API proof is required: atrinik/client only" + else + echo "MANUAL ${app_slug} selected repositories require current owner UI or App-authenticated API proof: atrinik/client only" + fi activation_status=PENDING if [[ $(jq -r '.workflow_activation.state' <<<"${app_record}") == observed-successful-owned-pr ]]; then activation_status=OWNER-OBSERVED diff --git a/config/manual-settings.json b/config/manual-settings.json index cb560d9..e1f02ff 100644 --- a/config/manual-settings.json +++ b/config/manual-settings.json @@ -578,10 +578,40 @@ "rotate_by": "2027-01-09", "rotation_cadence_days": 90, "rotation_owner": "Atrinik organization owners", + "accepted_key_exposures": [ + { + "exposure": "repository-transfer-retained-copy", + "exposed_names": [ + "CRATE_DEPENDENCY_UPDATE_APP_ID", + "CRATE_DEPENDENCY_UPDATE_APP_PRIVATE_KEY" + ], + "former_repository": "atrinik/editor", + "repository": "seraphinastudios/tilewright-editor", + "repository_id": 1327106902, + "exposure_started_on": "2026-10-17", + "removal_observed_on": "2026-10-18", + "accepted_by": "Atrinik organization owners", + "accepted_on": "2026-10-18", + "evidence_location": "README.md#crate-dependency-update-app" + } + ], "installation_id": 170332148, "status": "owner-observed", "repository_selection": "selected", "repository_scope_verification": "app_authenticated_repository_api", + "repository_scope_reverification": { + "state": "pending-reverification", + "reason": "repository-transfer-after-readback", + "last_readback_on": "2026-10-11", + "selection_changed_on": "2026-10-17", + "removed_repositories": [ + { + "repository": "atrinik/editor", + "repository_id": 1327106902 + } + ], + "evidence_location": "README.md#crate-dependency-update-app" + }, "owner": "Atrinik organization owners", "permissions": { "contents": "write", diff --git a/tests/check-project-health.sh b/tests/check-project-health.sh index c879a23..5e37c06 100755 --- a/tests/check-project-health.sh +++ b/tests/check-project-health.sh @@ -466,7 +466,7 @@ run_health() { ATRINIK_SETTINGS_TOKEN=settings-token \ ATRINIK_PROJECT_HEALTH_NOW=2026-08-10T05:00:00Z \ ATRINIK_PROJECT_HEALTH_SYNC_COMMAND="${temporary}/bin/sync-plan" \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ GITHUB_STEP_SUMMARY="${temporary}/step-summary" \ "${root}/bin/check-project-health" "$@" } @@ -675,7 +675,7 @@ if PATH="${temporary}/bin:${PATH}" \ GH_TOKEN=health-token ATRINIK_SETTINGS_TOKEN='' \ ATRINIK_PROJECT_HEALTH_NOW=2026-08-10T05:00:00Z \ ATRINIK_PROJECT_HEALTH_SYNC_COMMAND="${temporary}/bin/sync-plan" \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${root}/bin/check-project-health" --apply \ >"${temporary}/missing.out" 2>"${temporary}/missing.err"; then echo "error: health check accepted a missing settings credential" >&2 @@ -750,7 +750,7 @@ if PATH="${temporary}/bin:${PATH}" \ GH_TOKEN=health-token ATRINIK_SETTINGS_TOKEN='' \ ATRINIK_PROJECT_HEALTH_NOW=2026-08-10T05:00:00Z \ ATRINIK_PROJECT_HEALTH_SYNC_COMMAND="${temporary}/bin/sync-plan" \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${root}/bin/check-project-health" --apply \ >"${temporary}/reopen.out" 2>"${temporary}/reopen.err"; then echo "error: health check accepted a missing credential during recurrence" >&2 diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index ba4ceb4..5f3984d 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -4,7 +4,7 @@ set -euo pipefail root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) temporary=$(mktemp -d) trap 'rm -rf "${temporary}"' EXIT -export ATRINIK_VALIDATION_TODAY=2026-10-12 +export ATRINIK_VALIDATION_TODAY=2026-10-18 mkdir -p "${temporary}/bin" cp "${root}/bin/validate" "${temporary}/bin/validate" cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${temporary}/" @@ -115,6 +115,7 @@ done jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=85500002 | .installation_id=154000002 | .repository_scope_verification="organization_owner_ui" | .observed_on="2026-08-31" | .evidence_location="atrinik/github-settings#owner-app" | + .repository_scope_reverification=null | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" "${temporary}/bin/validate" >/dev/null @@ -146,6 +147,89 @@ for filter in \ '.crate_dependency_update_app.observed_on = null'; do assert_rejected "${filter}" done + +assert_accepted() { + local filter=$1 + jq "${filter}" "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" + if ! "${temporary}/bin/validate" >/dev/null 2>&1; then + echo "error: rejected valid dependency App record: ${filter}" >&2 + exit 1 + fi + reset_policy +} + +# Owner-accepted key exposures bind to the current key and a closed window. +# Rotation retires them: a new key cannot keep an older key's acceptance. +assert_accepted '.crate_dependency_update_app.accepted_key_exposures = []' +assert_accepted '.crate_dependency_update_app |= (.key_created_on = "2026-10-18" | + .last_verified_on = "2026-10-18" | .rotate_by = "2027-01-16" | .accepted_key_exposures = [])' +exposure='.crate_dependency_update_app.accepted_key_exposures' +for filter in \ + "${exposure} = null" \ + "${exposure} = {}" \ + "${exposure} += [${exposure}[0]]" \ + "${exposure}[0].private_key = \"forbidden\"" \ + "del(${exposure}[0].accepted_on)" \ + "${exposure}[0].exposure = \"accepted\"" \ + "${exposure}[0].exposed_names = [\"CRATE_DEPENDENCY_UPDATE_APP_ID\"]" \ + "${exposure}[0].exposed_names += [\"CARGO_REGISTRY_TOKEN\"]" \ + "${exposure}[0].exposed_names |= reverse" \ + "${exposure}[0].former_repository = \"atrinik/client\"" \ + "${exposure}[0].former_repository = \"seraphinastudios/tilewright-editor\"" \ + "${exposure}[0].repository = \"atrinik/editor\"" \ + "${exposure}[0].repository = \"Atrinik/editor\"" \ + "${exposure}[0].repository_id = 1327106844" \ + "${exposure}[0].repository_id = 0" \ + "${exposure}[0].exposure_started_on = \"2026-10-10\"" \ + "${exposure}[0].exposure_started_on = \"2026-02-30\"" \ + "${exposure}[0].removal_observed_on = null" \ + "${exposure}[0].removal_observed_on = \"2026-10-16\"" \ + "${exposure}[0].accepted_on = null" \ + "${exposure}[0].accepted_on = \"2026-10-17\"" \ + "${exposure}[0].accepted_on = \"2099-01-01\"" \ + "${exposure}[0].accepted_by = \"unknown\"" \ + "${exposure}[0].evidence_location = \"https://example.test/evidence?id=1\"" \ + '.crate_dependency_update_app |= (.key_created_on = "2026-10-18" | + .last_verified_on = "2026-10-18" | .rotate_by = "2027-01-16")'; do + assert_rejected "${filter}" +done + +# A selected-set change after the recorded readback stays pending until a +# fresh readback replaces it; then the pending record is cleared. +assert_accepted '.crate_dependency_update_app |= (.repository_scope_verification = "organization_owner_ui" | + .observed_on = "2026-10-18" | .repository_scope_reverification = null)' +reverification='.crate_dependency_update_app.repository_scope_reverification' +for filter in \ + "${reverification} = \"pending\"" \ + "${reverification} = {}" \ + "${reverification}.observed_on = \"2026-10-18\"" \ + "${reverification}.state = \"reverified\"" \ + "${reverification}.reason = \"unknown\"" \ + "${reverification}.last_readback_on = \"2026-10-10\"" \ + "${reverification}.last_readback_on = null" \ + "${reverification}.selection_changed_on = \"2026-10-11\"" \ + "${reverification}.selection_changed_on = \"2099-01-01\"" \ + "${reverification}.selection_changed_on = null" \ + "${reverification}.removed_repositories = []" \ + "${reverification}.removed_repositories = null" \ + "${reverification}.removed_repositories += [${reverification}.removed_repositories[0]]" \ + "${reverification}.removed_repositories[0] = {repository:\"atrinik/client\", repository_id:1327106844}" \ + "${reverification}.removed_repositories[0].repository_id = 1327106844" \ + "${reverification}.removed_repositories[0].repository = \"seraphinastudios/tilewright-editor\"" \ + "${reverification}.removed_repositories[0].visibility = \"public\"" \ + "${reverification}.evidence_location = null" \ + '.crate_dependency_update_app.observed_on = "2026-10-12"'; do + assert_rejected "${filter}" +done + +# A planned App has no observed readback or key to qualify. +planned='.crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | + .key_created_on=null | .last_verified_on=null | .rotate_by=null | + .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .credential_state="pending-name-metadata")' +assert_accepted "${planned} | .crate_dependency_update_app |= (.accepted_key_exposures=[] | .repository_scope_reverification=null)" +assert_rejected "${planned} | .crate_dependency_update_app.accepted_key_exposures=[]" +assert_rejected "${planned} | .crate_dependency_update_app.repository_scope_reverification=null" # Removing unactivated manual intent is a valid reviewed rollback. jq '.crates_io_trusted_publishing = []' "${root}/config/manual-settings.json" \ >"${temporary}/config/manual-settings.json" diff --git a/tests/validate-manual-credentials.sh b/tests/validate-manual-credentials.sh index 9ab3031..8d4d545 100755 --- a/tests/validate-manual-credentials.sh +++ b/tests/validate-manual-credentials.sh @@ -31,6 +31,7 @@ reset_manual_settings() { .crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | .key_created_on=null | .last_verified_on=null | .rotate_by=null | .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .accepted_key_exposures=[] | .repository_scope_reverification=null | .credential_state="pending-name-metadata" | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" @@ -235,7 +236,7 @@ jq '.github_actions_credentials |= map(select(.environment != "production")) | .runbook = "README.md#metaserver-production-credentials" ]' "${root}/config/manual-settings.json" >"${temporary}/production-credentials.json" cp "${temporary}/production-credentials.json" "${temporary}/config/manual-settings.json" -ATRINIK_VALIDATION_TODAY=2026-10-12 "${temporary}/bin/validate" >/dev/null +ATRINIK_VALIDATION_TODAY=2026-10-18 "${temporary}/bin/validate" >/dev/null for invalid in \ '.github_actions_credentials += [.github_actions_credentials[-1]]' \ @@ -245,7 +246,7 @@ for invalid in \ '.github_actions_credentials[-1].required_scopes += ["Workers Scripts Edit"]' \ '.github_actions_credentials[-1].credential_type = "cloudflare_account_token"'; do jq "$invalid" "${temporary}/production-credentials.json" >"${temporary}/config/manual-settings.json" - if ATRINIK_VALIDATION_TODAY=2026-10-12 "${temporary}/bin/validate" >/dev/null 2>&1; then + if ATRINIK_VALIDATION_TODAY=2026-10-18 "${temporary}/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted malformed PROD credential lifecycle: ${invalid}" >&2 exit 1 fi @@ -259,7 +260,7 @@ for invalid in \ '.github_actions_credentials[1].consumers = [".github/workflows/publish.yml"]' \ '.github_actions_credentials[1].credential_value = "secret"'; do jq "$invalid" "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" - if ATRINIK_VALIDATION_TODAY=2026-10-12 "${temporary}/bin/validate" >/dev/null 2>&1; then + if ATRINIK_VALIDATION_TODAY=2026-10-18 "${temporary}/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted malformed DEV credential lifecycle: ${invalid}" >&2 exit 1 fi diff --git a/tests/validate-planning-health.sh b/tests/validate-planning-health.sh index 8e18b0d..b0f815b 100755 --- a/tests/validate-planning-health.sh +++ b/tests/validate-planning-health.sh @@ -13,14 +13,14 @@ assert_invalid() { jq "${filter}" "${root}/config/planning-health.json" \ >"${temporary}/repository/config/planning-health.json" - if ATRINIK_VALIDATION_TODAY=2026-10-12 \ + if ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${temporary}/repository/bin/validate" >/dev/null 2>&1; then echo "error: validator accepted ${description}" >&2 exit 1 fi } -ATRINIK_VALIDATION_TODAY=2026-10-12 \ +ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${temporary}/repository/bin/validate" >/dev/null assert_invalid '.freshness_threshold_minutes = 30' \ diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 61000f3..0a340c2 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -822,7 +822,7 @@ run_verify() { FAKE_GH_SCENARIO="${scenario}" \ FAKE_CRATE_FIXTURES="${root}/tests/fixtures" \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${verification_root}/bin/verify-manual-settings" } @@ -882,10 +882,11 @@ cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${planned_u jq '.crate_dependency_update_app |= (.status="planned" | .app_id=null | .installation_id=null | .key_created_on=null | .last_verified_on=null | .rotate_by=null | .repository_scope_verification="pending-owner-ui" | .observed_on=null | .evidence_location=null | + .accepted_key_exposures=[] | .repository_scope_reverification=null | .credential_state="pending-name-metadata" | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' "${fixture_root}/config/manual-settings.json" \ >"${planned_updater_root}/config/manual-settings.json" -ATRINIK_VALIDATION_TODAY=2026-10-12 "${planned_updater_root}/bin/validate" >/dev/null +ATRINIK_VALIDATION_TODAY=2026-10-18 "${planned_updater_root}/bin/validate" >/dev/null if run_verify updater-present "${planned_updater_root}" >"${temporary}/updater-planned.out" 2>"${temporary}/updater-planned.err"; then echo "error: pending App accepted an unrecorded live installation" >&2 exit 1 @@ -898,11 +899,16 @@ cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${updater_r jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=5276152 | .installation_id=170332148 | .repository_scope_verification="organization_owner_ui" | .observed_on="2026-08-31" | .evidence_location="atrinik/github-settings#owner-app" | + .repository_scope_reverification=null | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${fixture_root}/config/manual-settings.json" >"${updater_root}/config/manual-settings.json" updater_output=$(run_verify updater-present "${updater_root}") grep -Fq 'KEEP atrinik/client crate dependency App credential name metadata' <<<"${updater_output}" -grep -Fq 'selected repositories require current owner UI or App-authenticated API proof: atrinik/client only' <<<"${updater_output}" +grep -Fq 'MANUAL atrinik-crate-dependency-update selected repositories require current owner UI or App-authenticated API proof: atrinik/client only' <<<"${updater_output}" +if grep -Fq 'await re-verification' <<<"${updater_output}"; then + echo "error: verifier reported pending re-verification after a fresh selected-set readback" >&2 + exit 1 +fi for scenario in updater-missing updater-permissions updater-selection updater-missing-secret updater-missing-variable; do if run_verify "${scenario}" "${updater_root}" >"${temporary}/updater-${scenario}.out" \ 2>"${temporary}/updater-${scenario}.err"; then @@ -929,6 +935,15 @@ grep -Fq 'KEEP atrinik/classic repository Actions secret DEPENDENCY_UPDATE_APP_P <<<"${output}" grep -Fq 'KEEP atrinik/classic repository Actions variable DEPENDENCY_UPDATE_APP_ID' \ <<<"${output}" +# The recorded transfer leaves the crate App's client-only set pending. +grep -Fq 'PENDING atrinik-crate-dependency-update selected repositories await re-verification: a 2026-10-17 repository transfer removed atrinik/editor after the 2026-10-11 readback; fresh owner UI or App-authenticated API proof is required: atrinik/client only' \ + <<<"${output}" +if grep -Fq 'MANUAL atrinik-crate-dependency-update selected repositories' <<<"${output}"; then + echo "error: verifier treated a stale selected-set readback as current" >&2 + exit 1 +fi +grep -Fq 'PENDING atrinik-crate-dependency-update owning workflow activation evidence: pending-reviewed-workflows' \ + <<<"${output}" grep -Fq 'KEEP atrinik organization pins match the exact governed order' <<<"${output}" grep -Fq 'Manual settings live credential, GitHub App, external provider App, Pages, environment, and organization pin metadata is present; planned integrations are recorded without live provisioning.' \ <<<"${output}" @@ -992,7 +1007,7 @@ output=$(PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO=environment-page2 \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${environment_page_root}/bin/verify-manual-settings") grep -Fq 'KEEP atrinik/classic environment discord-release metadata' <<<"${output}" grep -Fq 'deployment-branch-policies?per_page=100&page=2' "${temporary}/gh.log" @@ -1016,7 +1031,7 @@ output=$(PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" \ FAKE_GH_SCENARIO=shared-repository \ GITHUB_ACTIONS=true GH_TOKEN=test-token \ - ATRINIK_VALIDATION_TODAY=2026-10-12 \ + ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${shared_root}/bin/verify-manual-settings") grep -Fq 'SECOND_SETTINGS_TOKEN' <<<"${output}" [[ $(grep -Fc 'repos/atrinik/github-settings' "${temporary}/gh.log") == 2 ]] @@ -1232,7 +1247,7 @@ fi : >"${temporary}/gh.log" if PATH="${temporary}/bin:${PATH}" \ FAKE_GH_LOG="${temporary}/gh.log" FAKE_GH_SCENARIO=present \ - GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-10-12 \ + GITHUB_ACTIONS=true GH_TOKEN='' ATRINIK_VALIDATION_TODAY=2026-10-18 \ "${fixture_root}/bin/verify-manual-settings" \ >"${temporary}/empty.out" 2>"${temporary}/empty.err"; then echo "error: manual-settings verifier accepted an empty workflow credential" >&2 From 879a6dd1ab0c1a4c1d33b3c36a559f4cc0f5476d Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sun, 18 Oct 2026 02:06:30 -0500 Subject: [PATCH 2/2] fix: pin the editor transfer in the dependency App evidence Codex review on #101 found that the new App evidence schema accepted an unrelated repository in place of the editor and let either record disappear without the event that should retire it. - Pin both records to the exact 2026-10-17 editor transfer: atrinik/editor, now seraphinastudios/tilewright-editor, ID 1327106902. - Require the pending re-verification record while observed_on is on or before the transfer, and require it to be null once a later readback is recorded. Clearing it now needs fresh evidence. - Require the accepted exposure while the current key was created on or before the transfer, with exactly both exposed names, and require an empty list once rotation records a newer key. - Extend the negative tests with the substitutions and removals from the review, plus a readback on the transfer day. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 10 +-- CONTRIBUTING.md | 7 +- README.md | 22 +++--- bin/validate | 81 +++++++++++----------- tests/validate-crate-trusted-publishing.sh | 21 ++++-- tests/verify-manual-settings.sh | 2 +- 6 files changed, 81 insertions(+), 62 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 925f541..351c686 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,9 +174,10 @@ and a rotate-by deadline within the existing 90-day cadence. The deadline is internal policy, not upstream expiry; verify replacement workflow use before revoking the old key. Keep a selected-set change after the recorded readback - as pending `repository_scope_reverification` until fresh readback replaces - it. An owner-accepted key exposure binds to the current key and a closed - window, never extends rotate-by, and is removed on rotation. + as pending `repository_scope_reverification` until a later readback replaces + it. An owner-accepted key exposure stays until rotation, never extends + rotate-by, and is removed on rotation. Both records pin the exact editor + transfer. - Record GitHub Pages sites with stable repository identity, exact provider URL, HTTPS enforcement, owning deployment environment, desired Actions source, workflow path, and immutable deployment-action marker. Before that marker is @@ -234,7 +235,8 @@ - Remove a repository transferred out of the organization from every desired-state inventory, validator identity map and test fixture in one change, and record its transfer in `README.md#transferred-repositories`. - Historical evidence sections stay as written. + Historical evidence sections stay as written. Validated evidence records of + the transfer itself may pin the former identity. - Update this `AGENTS.md` in the same change when major rework alters governance ownership, policy scope, publisher behavior, required checks, or validation. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d51855..45e045a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,9 +252,10 @@ creation; that internal deadline does not assert upstream expiry. Verify a replacement key in the owning writing workflow before revoking the old key. Keep App-authenticated selected-set readback or owner UI evidence, and do not infer the webhook switch from an empty event set. A -selected-set change after that readback stays pending re-verification until -fresh evidence is recorded. Owner-accepted key exposures bind to the current key -and a closed window and never extend the rotate-by deadline. +selected-set change after that readback stays pending re-verification until a +later readback is recorded. An owner-accepted key exposure stays until the key +rotates and never extends the rotate-by deadline; both records pin the exact +editor transfer. Private linux-build consumption needs separate client-only read grants through owner UI. Keep exact private package/source identities and pending grant state until observed; metadata and owner pulls do not prove an effective owning diff --git a/README.md b/README.md index a05ed10..e00fa6c 100644 --- a/README.md +++ b/README.md @@ -1845,10 +1845,12 @@ To re-verify, the owner confirms in the installation's organization-owner UI, or through supported App-authenticated readback, that the selected set is exactly `atrinik/client`. Reviewed source then records that method in `repository_scope_verification`, the readback date in `observed_on`, and sets -`repository_scope_reverification` to `null`. Validation accepts a pending record -only for an owner-observed App, only when its readback date equals `observed_on` -and the change came after it, and only for removed organization repositories -that are no longer selected. Preserve every other App and installation, +`repository_scope_reverification` to `null`. Validation pins the record to the +editor transfer. While `observed_on` is on or before 2026-10-17, the pending +record must name exactly that readback, the 2026-10-17 change and +`atrinik/editor` (`1327106902`). It can be cleared only together with a +readback dated after the transfer, and must be cleared once one is recorded. +Preserve every other App and installation, including the existing Classic updater and provider Apps. The desired disabled webhook is a separate owner UI setting; `events: []` proves no subscriptions and does not independently prove the webhook switch is disabled. @@ -1888,11 +1890,13 @@ accepted that exposure window instead of rotating the key early.** `accepted_key_exposures` records the acceptance: the exposure type, the exposed names, the former and current repository identity, the exposure start, the removal readback and the acceptance date. The key keeps its ordinary 2027-01-09 -rotate-by deadline; the acceptance does not extend it. Validation binds each -accepted exposure to the current key and to a closed window: the exposure cannot -start before `key_created_on`, its removal must be observed, and acceptance -cannot precede that observation. Rotating the key therefore retires the record; -remove it in the same reviewed change that records the new key's dates. +rotate-by deadline; the acceptance does not extend it. Validation pins the +record to the editor transfer (former and current name, ID, the 2026-10-17 +start and both exposed names), and requires an observed removal with the +acceptance on or after it. The record must stay while the current key was +created on or before the transfer. Once a rotation records a newer +`key_created_on`, the list must be empty, so remove the record in that same +reviewed change. The read-only verifier checks live installation identity, exact permissions, empty events, active state, selected repository identities, and credential diff --git a/bin/validate b/bin/validate index fe21bbf..243140e 100755 --- a/bin/validate +++ b/bin/validate @@ -981,13 +981,18 @@ jq -e --arg today "${validation_today}" ' def observed_day: (type == "string" and test("^[0-9]{4}-[0-9]{2}-[0-9]{2}$")) and (try ((. + "T00:00:00Z" | fromdateiso8601 | strftime("%Y-%m-%d")) == . and . <= $today) catch false); - def positive_id: type == "number" and floor == . and . > 0; - def repository_name: type == "string" and test("^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$"); - def organization_repository: repository_name and startswith("atrinik/"); def evidence_reference: type == "string" and test("^[A-Za-z0-9_./#-]+$"); + # The 2026-10-17 editor transfer (README.md#transferred-repositories) removed + # a selected repository after the 2026-10-11 readback and carried a copy of + # the then-current key. Its evidence records pin this exact identity. + def editor_transfer: { + former_repository: "atrinik/editor", repository: "seraphinastudios/tilewright-editor", + repository_id: 1327106902, transferred_on: "2026-10-17" + }; .github_actions_apps as $apps | .external_provider_apps as $external_apps | .crate_dependency_update_app as $app | + editor_transfer as $transfer | ($app | keys == ["accepted_key_exposures", "app_id", "app_slug", "consumers", "credential_state", "events", "evidence_location", "homepage_url", "installation_id", "key_created_on", "last_verified_on", "observed_on", "owner", "permissions", "pull_request_policy", "readiness", "repositories", "repository_scope_reverification", "repository_scope_verification", "repository_selection", "rotate_by", @@ -1031,48 +1036,46 @@ jq -e --arg today "${validation_today}" ' ($app.app_id | type == "number" and floor == . and . > 0) and ($app.installation_id | type == "number" and floor == . and . > 0) and ($app.repository_scope_verification | IN("organization_owner_ui", "app_authenticated_repository_api")) and - # A selected-set change after the recorded readback leaves the expected set - # pending re-verification until fresh owner UI or App-authenticated proof. - ($app.repository_scope_reverification == null or ( + # A readback on or before the transfer no longer proves the selected set: + # keep it pending until a later readback replaces observed_on. + (if $app.observed_on <= $transfer.transferred_on then $app.repository_scope_reverification as $pending | + ($pending | type == "object") and ($pending | keys == ["evidence_location", "last_readback_on", "reason", "removed_repositories", "selection_changed_on", "state"]) and $pending.state == "pending-reverification" and $pending.reason == "repository-transfer-after-readback" and - ($pending.last_readback_on | observed_day) and $pending.last_readback_on == $app.observed_on and - ($pending.selection_changed_on | observed_day) and - $pending.selection_changed_on > $pending.last_readback_on and - ($pending.removed_repositories | type == "array" and length > 0) and - all($pending.removed_repositories[]; - . as $removed | - (type == "object") and (keys == ["repository", "repository_id"]) and - (.repository | organization_repository) and (.repository_id | positive_id) and - all($app.repositories[]; - .repository != $removed.repository and .repository_id != $removed.repository_id)) and - ($pending.removed_repositories | map(.repository_id) | length == (unique | length)) and + $pending.last_readback_on == $app.observed_on and + $pending.selection_changed_on == $transfer.transferred_on and + $pending.last_readback_on < $pending.selection_changed_on and + $pending.removed_repositories == + [{repository: $transfer.former_repository, repository_id: $transfer.repository_id}] and ($pending.evidence_location | evidence_reference) - )) and - # An owner-accepted exposure applies only to the current key and closed - # windows; it never extends the rotate-by deadline. - all($app.accepted_key_exposures[]; - . as $exposure | - (type == "object") and - (keys == ["accepted_by", "accepted_on", "evidence_location", "exposed_names", "exposure", "exposure_started_on", - "former_repository", "removal_observed_on", "repository", "repository_id"]) and - .exposure == "repository-transfer-retained-copy" and - (.exposed_names | type == "array" and length > 0 and . == unique and - any(.[]; . == $app.secret_name) and all(.[]; . == $app.secret_name or . == $app.variable_name)) and - (.former_repository | organization_repository) and - (.repository | repository_name and (ascii_downcase | startswith("atrinik/") | not)) and - (.repository_id | positive_id) and - all($app.repositories[]; - .repository != $exposure.former_repository and .repository_id != $exposure.repository_id) and - (.exposure_started_on | observed_day) and .exposure_started_on >= $app.key_created_on and - (.removal_observed_on | observed_day) and .removal_observed_on >= .exposure_started_on and - (.accepted_on | observed_day) and .accepted_on >= .removal_observed_on and - .accepted_by == $app.owner and - (.evidence_location | evidence_reference)) and - ($app.accepted_key_exposures | length == (unique | length)) and + else + $app.repository_scope_reverification == null + end) and + # A key that was current at the transfer keeps its exposure acceptance + # until rotation; a newer key has none. Acceptance never extends rotate-by. + (if $app.key_created_on <= $transfer.transferred_on then + ($app.accepted_key_exposures | length == 1) and + ($app.accepted_key_exposures[0] as $exposure | + ($exposure | type == "object") and + ($exposure | keys == ["accepted_by", "accepted_on", "evidence_location", "exposed_names", "exposure", + "exposure_started_on", "former_repository", "removal_observed_on", "repository", "repository_id"]) and + $exposure.exposure == "repository-transfer-retained-copy" and + $exposure.exposed_names == ([$app.secret_name, $app.variable_name] | sort) and + $exposure.former_repository == $transfer.former_repository and + $exposure.repository == $transfer.repository and + $exposure.repository_id == $transfer.repository_id and + $exposure.exposure_started_on == $transfer.transferred_on and + ($exposure.removal_observed_on | observed_day) and + $exposure.removal_observed_on >= $exposure.exposure_started_on and + ($exposure.accepted_on | observed_day) and $exposure.accepted_on >= $exposure.removal_observed_on and + $exposure.accepted_by == $app.owner and + ($exposure.evidence_location | evidence_reference)) + else + $app.accepted_key_exposures == [] + end) and ($app.observed_on | observed_day) and ($app.key_created_on | observed_day) and ($app.last_verified_on | observed_day) and $app.last_verified_on >= $app.key_created_on and diff --git a/tests/validate-crate-trusted-publishing.sh b/tests/validate-crate-trusted-publishing.sh index 5f3984d..6108f83 100755 --- a/tests/validate-crate-trusted-publishing.sh +++ b/tests/validate-crate-trusted-publishing.sh @@ -115,7 +115,7 @@ done jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=85500002 | .installation_id=154000002 | .repository_scope_verification="organization_owner_ui" | .observed_on="2026-08-31" | .evidence_location="atrinik/github-settings#owner-app" | - .repository_scope_reverification=null | + .repository_scope_reverification.last_readback_on="2026-08-31" | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${root}/config/manual-settings.json" >"${temporary}/config/manual-settings.json" "${temporary}/bin/validate" >/dev/null @@ -158,14 +158,17 @@ assert_accepted() { reset_policy } -# Owner-accepted key exposures bind to the current key and a closed window. -# Rotation retires them: a new key cannot keep an older key's acceptance. -assert_accepted '.crate_dependency_update_app.accepted_key_exposures = []' +# The exposure acceptance pins the editor transfer and stays while the key +# that was current at the transfer is in use. Rotation retires it. assert_accepted '.crate_dependency_update_app |= (.key_created_on = "2026-10-18" | .last_verified_on = "2026-10-18" | .rotate_by = "2027-01-16" | .accepted_key_exposures = [])' exposure='.crate_dependency_update_app.accepted_key_exposures' for filter in \ "${exposure} = null" \ + "${exposure} = []" \ + "${exposure}[0] |= (.former_repository = \"atrinik/classic\" | .repository = \"elsewhere/classic\" | .repository_id = 1327289971)" \ + "${exposure}[0].repository = \"seraphinastudios/other-editor\"" \ + "${exposure}[0].exposure_started_on = \"2026-10-18\"" \ "${exposure} = {}" \ "${exposure} += [${exposure}[0]]" \ "${exposure}[0].private_key = \"forbidden\"" \ @@ -194,13 +197,19 @@ for filter in \ assert_rejected "${filter}" done -# A selected-set change after the recorded readback stays pending until a -# fresh readback replaces it; then the pending record is cleared. +# A readback on or before the transfer stays pending; only a later readback +# in observed_on may clear the record, and then it must be cleared. assert_accepted '.crate_dependency_update_app |= (.repository_scope_verification = "organization_owner_ui" | .observed_on = "2026-10-18" | .repository_scope_reverification = null)' reverification='.crate_dependency_update_app.repository_scope_reverification' for filter in \ + "${reverification} = null" \ "${reverification} = \"pending\"" \ + '.crate_dependency_update_app |= (.observed_on = "2026-10-17" | .repository_scope_reverification = null)' \ + '.crate_dependency_update_app.observed_on = "2026-10-18"' \ + '.crate_dependency_update_app |= (.observed_on = "2026-10-17" | .repository_scope_reverification.last_readback_on = "2026-10-17")' \ + "${reverification}.removed_repositories = [{repository:\"atrinik/classic\", repository_id:1327289971}]" \ + "${reverification}.selection_changed_on = \"2026-10-16\"" \ "${reverification} = {}" \ "${reverification}.observed_on = \"2026-10-18\"" \ "${reverification}.state = \"reverified\"" \ diff --git a/tests/verify-manual-settings.sh b/tests/verify-manual-settings.sh index 0a340c2..1411aa6 100755 --- a/tests/verify-manual-settings.sh +++ b/tests/verify-manual-settings.sh @@ -898,7 +898,7 @@ cp "${root}/bin/validate" "${fixture_root}/bin/verify-manual-settings" "${update cp -R "${root}/config" "${root}/community-health" "${root}/.github" "${updater_root}/" jq '.crate_dependency_update_app |= (.status="owner-observed" | .app_id=5276152 | .installation_id=170332148 | .repository_scope_verification="organization_owner_ui" | - .observed_on="2026-08-31" | .evidence_location="atrinik/github-settings#owner-app" | + .observed_on="2026-10-18" | .evidence_location="atrinik/github-settings#owner-app" | .repository_scope_reverification=null | .workflow_activation={state:"pending-reviewed-workflows", observed_on:null, evidence_location:null})' \ "${fixture_root}/config/manual-settings.json" >"${updater_root}/config/manual-settings.json"