Skip to content

combo-verify: hand the matrix slot numbers, never the roster — the dropped job output created no verify job - #6453

Merged
rbuergi merged 7 commits into
mainfrom
fix/3848-combo-verify-slots
Oct 10, 2026
Merged

rbuergi merged 7 commits into
mainfrom
fix/3848-combo-verify-slots

Conversation

@rbuergi

@rbuergi rbuergi commented Oct 10, 2026 •

Copy link
Copy Markdown
Contributor

Refs #3848

The defect

combo-verify.yml's preflight published the derived roster as the job output instances (and the source map as sources), and the verify matrix was fromJSON(needs.preflight.outputs.instances).

The roster names installations that a client estate keeps private. The derivation registers those names as log masks, and the runner drops any job output that contains a masked value: the whole output, as a warning, inside a job that concludes success:

##[warning]Skip output 'instances' since it may contain secret.
Set output 'count'
##[warning]Skip output 'sources' since it may contain secret.

The matrix received an empty string, could not expand, and no per-instance job was ever created. Every post-CD run read preflight=success verify=failure instances=6 with no instance contacted and no reason printed. The only green runs were the "CD cancelled, no candidate" branch.

The change

Design choice: each verify job re-derives its own roster row, instead of receiving the roster as an artifact (per maintainer standing instruction to follow recommendations). The issue's 10-10 comment names both routes. An artifact was rejected because this repository is public: an artifact can be downloaded, and its name and the file names inside it are not masked.

  • Preflight hands over three values that cannot contain a name: slots ([0,1,…,count-1]), count, and digest. The digest is a sha256 over roster and source map, salted with the private roster, written as single digits joined by underscores, so no identifier or numeric secret can be a substring of it.

  • Each verify job resolves its slot itself. It runs the same script the preflight ran (derive-combo-instances.py --discover --slot N --expect-count … --expect-digest …), takes the row at its slot, and writes name, base URL and sources to $GITHUB_ENV. It exits 1 unless it derived the preflight's count and digest, so a fleet that changed between the two jobs is a red naming the drift.

  • Nothing unmasked carries a name. The job is Verify instance slot N against its roll target; the work-root artifact and the lander's files are named slot-N. Before this, a job name built from matrix.instance.name would have published a private name the first time the matrix expanded.

  • The verdict counts jobs. needs.verify.result is one word for the whole matrix. The verdict job now lists the run's jobs over REST (actions: read), counts those matching the verify job's exact name, and fails unless created == succeeded == the preflight's count. Zero created has its own message naming the unexpanded matrix. No continue-on-error, no input-shaped if:.

  • A private roster row prints the verdict and counts only, and uploads nothing. Safe default, pending the maintainer's word. A failed verification used to upload the instance's combo.json (its module list) from this public repository. The slot step now writes INSTANCE_PRIVATE for a row the private roster names by instance id, host or declaring repository. For such a row the lander prints no name, host, response body or module id and withholds the verifier's output; the artifact upload runs only when INSTANCE_PRIVATE is literally false, so an unset value uploads nothing. The estate's own instances keep the upload and the full log. The verdict is still landed on the instance in full.

No skip-trapdoor was added: verify is still gated by needs: preflight alone, and the slot step and the job count are unconditional.

Tests and guards

check-combo-verify.py (runs on every platform PR, because this workflow never runs on one):

  • Structure: preflight outputs are exactly slots/count/digest; the matrix is built from slots; nothing in verify reads a name from the matrix or a preflight output; the slot is resolved before the lander with count and digest passed; the verdict counts jobs unconditionally.
  • Roster step, executed: every value it writes to $GITHUB_OUTPUT is put through the runner's own rule. A value containing any name, host or source of the roster fails.
  • Verdict step, executed over the job counts, including the measured case (6 derived, 0 jobs created), a partial matrix, a missing count, and the no-candidate branch.
  • --self-test: re-publishing the roster, building the matrix from it, naming the job or artifact after the installation, removing the slot step or its digest, removing or softening the job count, re-enabling the upload for a private row, and a verdict that never counts are each refused.

derive-combo-instances.py --self-test drives the slot resolution: the right row, and a red for an out-of-range slot, a missing count or digest, a roster that changed size, a moved host, and a changed source map.

check result
derive-combo-instances.py --self-test exit 0, 39 PASS
check-combo-verify.py --root . exit 0, 36 PASS, 0 violations
check-combo-verify.py --self-test exit 0, every regression refused
negative control: the new guard over origin/main's workflow exit 1, 14 structural errors
live, public deployments repository only: derive, then --slot 0..3 with the derived count and digest each slot resolved its row; a wrong digest exits 1 and writes nothing
the real lander driven end to end with stub curl/dotnet, as a private row (flag unset and true) and as a public one private: 0 occurrences of name, host or module id in log and summary; public: full detail
check-workflow-timeouts, -shell, -yaml-keys, -permission-pairing, check-guard-step-masking, check-pr-secret-preflight, check-reusable-workflow-runners exit 0
actionlint, shellcheck exit 0
dotnet build src/MeshWeaver.Documentation -c Release -warnaserror 0 warnings, 0 errors

Not run: the workflow itself. It triggers only on workflow_run and workflow_dispatch, so its first real execution is the first CD after this merges. The full six-instance derivation needs the fleet-reader App token and the private roster, which I do not hold.

Doc page: Doc/Architecture/ComboGateWiring → "The roster never crosses a job boundary — only slot numbers do".

Still owed, and not something code can do

Once the matrix expands, each instance's verify job reaches that instance as the run's own OIDC identity. An instance verifies only when it holds both halves below (from the issue's 2026-10-09 comment). An instance missing either one shows a 401 in its own slot's job, naming both.

  1. The audience config, Plugins__Registry__BuildPrincipalAudience = the instance's base URL. It is declared for memex, memex-cloud, build and client-b (Systemorph/Memex#698) and reaches each portal at its next Reconcile or Roll. The two client-estate instances in the roster need the same key in that estate's own deployments repository; that is the client estate's call.
  2. The grant node Admin/_BuildPrincipal/systemorph--meshweaver, created by a global admin on each instance. It exists on memex.systemorph.com; build, memex-cloud and client-b still need it, and so do the client-estate instances if they stay in the roster:
    • repository: Systemorph/MeshWeaver, repositoryId: 743606761, repositoryOwnerId: 77832550
    • events: {workflow_run: [verify], workflow_dispatch: [verify]}
    • eventRefs for both events: [refs/heads/main]
    • scopes: [verify:combo]

Two more things for a maintainer to know:

  • Private-row diagnostics are the maintainer's call. The default taken here withholds a client-estate row's module list and verifier output from this repository's public log, summary and artifacts. If the estate should get those somewhere private, that needs a destination this workflow does not have.
  • Closing reading for the issue (unchanged): a combo-verify run after a successful CD in which the verdict prints verify-jobs=N verify-jobs-succeeded=N for the full roster and every slot reaches landed: with a Green verdict. The first run after this merge should show N per-instance jobs existing at all; until the provisioning above is done, most of them will be red with the 401.

No recycle is needed: this changes CI only, nothing a per-node hub serves.

🤖 Generated with Claude Code

)

The preflight published the derived roster as the job output `instances`.
The roster names installations the private roster masks, and the runner
drops any job output that contains a masked value ("Skip output
'instances' since it may contain secret") as a warning inside a job that
concludes success. The verify matrix then received an empty string, could
not expand, and no per-instance job was ever created: every post-CD run
read preflight=success verify=failure with no instance contacted and no
reason printed.

- preflight hands over `slots` ([0..n-1]), `count` and a salted `digest`
  (decimal digits), none of which can contain a name
- each verify job re-derives the roster with the same script
  (`derive-combo-instances.py --slot`), takes its row, and refuses unless
  count and digest match the preflight's
- job names, artifact names and the lander's file names carry the slot,
  never the installation name (neither is masked; the repo is public)
- the verdict job counts the per-instance jobs the run created and reds
  unless created == succeeded == count; zero created has its own message
- check-combo-verify.py asserts the hand-over's structure, applies the
  runner's drop rule to every value the roster step emits, and executes
  the verdict over the job counts; --self-test refuses each regression

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 20:57
…loads nothing (Refs #3848)

This repository is public, and a failed verification uploaded the
instance's combo.json (its module list). For a row the private roster
names (by instance id, host or declaring repository) the slot step now
writes INSTANCE_PRIVATE=true, the lander prints no name, host, response
body or module id and withholds the verifier's output, and the artifact
upload runs only when INSTANCE_PRIVATE is literally 'false' (fails
closed). The verdict is still landed on the instance in full.

check-combo-verify.py refuses an upload under any looser condition; its
self-test re-enables the upload for a private row and requires a refusal.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Public artifacts can expose private module inventories, and digest masking collisions can still suppress matrix creation.

4 open findings
What changed in this PR

Reworks combo verification so private fleet rosters no longer cross GitHub Actions job boundaries.

Changes:

  • Uses opaque matrix slots plus count/digest validation.
  • Re-derives each slot and counts actual verification jobs.
  • Adds regression guards and documentation.
File Description
ComboGateWiring.md Documents slot-based handover.
combo-verify.yml Rebuilds the matrix and verdict logic.
derive-combo-instances.py Adds digest and slot resolution.
combo-verify-instance.sh Uses slot-based filenames.
check-combo-verify.py Adds structural and behavioral guards.

🧠 Review effort: Balanced


💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread .github/workflows/combo-verify.yml
Comment thread .github/scripts/check-combo-verify.py
Comment thread .github/scripts/derive-combo-instances.py Outdated
Comment thread src/MeshWeaver.Documentation/Data/Architecture/ComboGateWiring.md Outdated
Copilot AI balanced review requested due to automatic review settings October 10, 2026 21:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread .github/scripts/derive-combo-instances.py Outdated
Comment thread .github/scripts/derive-combo-instances.py Outdated
Comment thread .github/scripts/lock-pinned-digests.py Outdated
Comment thread .github/scripts/combo-verify-instance.sh Outdated
…'s name and paths (Refs #3848)

Review findings on #6453:

- The roster digest was plain decimal digits. An all-digit installation
  id or a numeric secret (a GitHub App id) can be a substring of it, and
  a job output containing a masked value is dropped. It is now single
  digits joined by underscores, so a value can occur inside it only if
  the value itself alternates digits with underscores. The self-test
  holds the rule and searches 400 digests for a counterexample, with the
  separator-less form as the control that the search can find one.
- check-combo-verify.py asserted ARTIFACT_TAG on the lander but never
  the upload step's own name and path. It now requires the exact slot
  name and the exact two slot files, and exactly one upload step; the
  self-test mutates each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 21:08
…and applied before any print (Refs #3848)

Review findings on #6453, second round:

- A row private only through its declaring repository had its derived
  name and host printed by the preflight report and never masked. Privacy
  is now decided before anything is printed: the report counts a private
  row without naming it (log and summary), and every derived id and host
  of a private installation is registered as a log mask first.
- The declaring-repository lookup was keyed by bare id, so a live private
  row sharing its id with a retired public installation could be
  classified public. It is keyed by (name, base URL) of live rows.
- Private identifiers under four characters were dropped from the set
  used to classify. Classification now reads the complete set with exact
  matching; only the log-mask set keeps the length floor.
- The lander's 401 guidance told the operator to set the audience to the
  private row's description. It now says where the real value is recorded.

Each has a self-test arm, the bare-id lookup with a control.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread .github/scripts/derive-combo-instances.py Outdated
Comment thread .github/scripts/lock-pinned-digests.py Outdated
Comment thread .github/scripts/derive-combo-instances.py
Copilot AI balanced review requested due to automatic review settings October 10, 2026 21:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread .github/scripts/derive-combo-instances.py
Comment thread .github/scripts/derive-combo-instances.py Outdated
…e ids are redacted from diagnostics (Refs #3848)

Review findings on #6453, third round:

- Classification failed open when the private-roster secret was missing:
  with no private entries every client row read as public. A row is now
  public only when the repository declaring it is in the COMMITTED
  .github/acr-retention/instances.json; everything else is private,
  whether or not the secret is present.
- A private id too short to be a log mask was still printed by blocker
  and exclusion lines. Those lines now pass through a redactor built from
  the complete private set (id, host, repository), and an uncommitted
  repository is not named in the scan lines.

Self-test arms for both, the redaction with a control.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 21:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Private source identifiers and excluded rows can still leak, while an optional digest key permits offline identifier confirmation.

3 open findings
2 resolved since last review

🧠 Review effort: Balanced

Comment thread .github/scripts/derive-combo-instances.py
Comment thread .github/scripts/derive-combo-instances.py
Comment thread .github/scripts/derive-combo-instances.py
rbuergi and others added 2 commits October 10, 2026 23:40
…edacted, digest keyed (Refs #3848)

Review findings on #6453, fourth round:

- A client deployment record's registry sources (name and URL) were
  printed to the log and the step summary. A source only an uncommitted
  repository declares is now masked and shown as <private>.
- The step summary's excluded line bypassed the redactor.
- The roster digest was salted with the optional private roster only, so
  without that secret it was a plain hash of low-entropy names. It is now
  an HMAC keyed with a secret both jobs hold; the derivation refuses to
  run in CI without the key, and the guard requires the same key in both.

Self-test arms for each.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 10, 2026 21:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The digest does not bind privacy provenance, allowing classification to change between preflight and verification.

2 open findings
3 resolved since last review

🧠 Review effort: Balanced

alternates single digits with underscores. No identifier and no credential has that shape, and
`digest_can_contain` states the rule so the self-test can hold it."""
salt = os.environ.get(lock.PRIVATE_ROSTER_ENV, "").strip()
body = json.dumps({"instances": rows, "sources": sources}, separators=(",", ":"), sort_keys=True)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not changing this, because the transition described is not a leak. The digest and the privacy bit answer two different questions on purpose.

  • The digest answers "is this the same installation the preflight counted?", so a verdict cannot land on a different one. Name, URL and source map are what identify it.
  • INSTANCE_PRIVATE is decided by the verify job's own derivation, at the moment it is about to print and upload, from where the row is declared at that moment. The preflight's view of privacy governs nothing in the verify job.

In the transition you describe, the row's overlay has moved into a repository listed in the committed, public instances.json before the verify job reads it. Its name and host are then already published in a public repository, so classifying it public at that point is correct, not a flip to an unsafe state. The opposite move (committed to uncommitted) makes the verify job classify it private, which is the safe direction.

Binding the preflight's bit into the digest would turn that legitimate mid-run change into a red and protect nothing: the bit that decides what is printed would still be the verify job's own.

Comment on lines +406 to +411
Each `verify` job then runs the **same** derivation the preflight ran
(`derive-combo-instances.py --discover --slot N --expect-count … --expect-digest …`), takes the row at
its slot, and writes the installation's name, base URL and source map to `$GITHUB_ENV` — inside one
job, where a masked value is masked rather than dropped. The script refuses unless it derived the
preflight's count **and** the preflight's digest, so a fleet that changed between the two jobs is a
red naming the drift, never a verdict landed on a different installation than the one counted.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The sentence claims the digest proves the verify job derived the same roster (names, URLs, source map), and that is what it checks. Privacy classification is deliberately not part of that claim: each verify job classifies its own row from where it is declared when the job runs. The reasoning is in my reply on the sibling thread: #6453 (comment). No doc change, since the page does not say the digest binds privacy.

@github-actions

Copy link
Copy Markdown
Contributor

Test Results

    24 files      24 suites   53m 35s ⏱️
11 623 tests 11 430 ✅ 193 💤 0 ❌
11 637 runs  11 444 ✅ 193 💤 0 ❌

Results for commit 10e290d.

@rbuergi
rbuergi merged commit 7b82b7b into main Oct 10, 2026
75 of 77 checks passed
@rbuergi
rbuergi deleted the fix/3848-combo-verify-slots branch October 11, 2026 07:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants