Repository navigation
combo-verify: hand the matrix slot numbers, never the roster — the dropped job output created no verify job - #6453
Conversation
) 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>
…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>
There was a problem hiding this comment.
🟡 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.
There was a problem hiding this comment.
🟡 Changes recommended
Private-row classification gaps can still expose client installation details and inventories.
7 open findings
Mask derived identifiers for private repository rows · New Preserve repository identity for duplicate instance IDs · New Preserve privacy classification for short private identifiers · New Decimal digest can collide with masked identifier values Privacy guard does not validate artifact name or path Use the external instance URL in private 401 guidance · New Documentation overlooks sensitive content in published evidence
1 resolved since last review
🧠 Review effort: Balanced
…'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>
…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>
There was a problem hiding this comment.
🟡 Changes recommended
The digest can still collide with valid masked identifiers, and several private-row paths can disclose or misclassify client data.
5 open findings
Private hostnames are not masked before external commands · New Short private IDs are omitted from privacy classification · New Mask derived identifiers for private repository rows Digest encoding can collide with registered private IDs · New Documentation overlooks sensitive content in published evidence
5 resolved since last review
🧠 Review effort: Balanced
…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>
…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>
| 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) |
There was a problem hiding this comment.
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_PRIVATEis 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.
| 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. |
There was a problem hiding this comment.
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.
Test Results 24 files 24 suites 53m 35s ⏱️ Results for commit 10e290d. |



Refs #3848
The defect
combo-verify.yml's preflight published the derived roster as the job outputinstances(and the source map assources), and theverifymatrix wasfromJSON(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: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=6with 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, anddigest. 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 namedslot-N. Before this, a job name built frommatrix.instance.namewould have published a private name the first time the matrix expanded.The verdict counts jobs.
needs.verify.resultis one word for the whole matrix. Theverdictjob 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. Nocontinue-on-error, no input-shapedif:.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 writesINSTANCE_PRIVATEfor 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 whenINSTANCE_PRIVATEis literallyfalse, 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:
verifyis still gated byneeds: preflightalone, 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):slots/count/digest; the matrix is built fromslots; nothing inverifyreads 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.$GITHUB_OUTPUTis put through the runner's own rule. A value containing any name, host or source of the roster fails.--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-testdrives 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.derive-combo-instances.py --self-testcheck-combo-verify.py --root .check-combo-verify.py --self-testorigin/main's workflow--slot0..3 with the derived count and digestcurl/dotnet, as a private row (flag unset andtrue) and as a public onecheck-workflow-timeouts,-shell,-yaml-keys,-permission-pairing,check-guard-step-masking,check-pr-secret-preflight,check-reusable-workflow-runnersactionlint,shellcheckdotnet build src/MeshWeaver.Documentation -c Release -warnaserrorNot run: the workflow itself. It triggers only on
workflow_runandworkflow_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.
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.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: 77832550events: {workflow_run: [verify], workflow_dispatch: [verify]}eventRefsfor both events:[refs/heads/main]scopes: [verify:combo]Two more things for a maintainer to know:
combo-verifyrun after a successful CD in which the verdict printsverify-jobs=N verify-jobs-succeeded=Nfor the full roster and every slot reacheslanded: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