Register the ten distribution surfaces and test that they agree (#497) - #525
Conversation
One engine is published through `action.yml`, `plugins/`, `skills/`, `adoption-kits/`, `harness/`, `examples/`, `prompts/`, `policies/`, `tools/` and the MCP server, and nothing checked that they said the same thing. #485 is what that costs: after #431 taught the CLI to read an MCP server's tool surface out of TypeScript or Go source, the zero-install detector went on answering `is_agent_project: false` for the vendor servers the CLI now accepts, and CI stayed green. `docs/distribution-surfaces.md` lists every surface, what it claims, and which test proves it. `tests/test_distribution_surface_parity.py` is that test, and `CONTRIBUTING.md` and `CLAUDE.md` point at both. A surface that answers nothing the engine answers still gets a row saying so. A new top-level directory fails the suite until somebody classifies it. Four surfaces were disagreeing with the engine, and are repaired here: - The bundled setup prompt told a coding agent to derive `agent.declared_purpose[]` from the README. `init` returns `control.next_action.actor: "human"` and `permissions.edit: false` for that field. The Codex kit's recipes page and the design-partner runbook carried the same instruction as a blanket "replace every CHANGE_ME". - The Claude Code kit rendered `@v0.16.0` and `agents-shipgate==0.16.0` into an adopter's CI — a tag and a release that do not exist. The drift was invisible because `test_claude_code_skill_source_matches_renderer` skipped that one file; removing the exemption is the actual repair. - The plugin's `plugin.json` and marketplace entry both demanded "runtime contract 15" beside `pipx install agents-shipgate`, which yields contract 10. The number was a second copy of what the bundled skill already states, so it is removed rather than re-synced. - The design-partner runbook named `v0.15.0`, demanded a contract that build has never implemented, floored pip at `>=0.13`, and gave a read order starting at `control.state`, which `shipgate.agent_handoff/v1` does not emit. #485's exact case is a minimized TypeScript and Go fixture under `tests/fixtures/distribution_parity/` and a parity row that fails today and passes when the port lands, with `xfail(strict=True)` so the exemption itself fails once it is unnecessary. #506's two unpublished-pin gaps are recorded the same way, against a ledger of exactly the files that diverge. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven findings from the review pass, all real: - The `harness` registry row named `test_surface_states_only_engine_merge_verdicts`, which had been renamed out of existence during the first self-review. The registry's central promise — which test proves the claim — was already false in the change that introduces it. Claims are now claim-keyed to their proving tests, `test_every_claim_names_a_test_that_exists` resolves every name against this module, and `test_every_scanned_claim_actually_has_rows` catches the weaker failure: a claim naming a real test whose scan matches no file on that surface. That caught `design_partner_runbook`'s `executable_pin`, which carried only a `>=` install floor no pin pattern looked at — so floors are now checked too, for reachability rather than equality. - The tag corroboration read `CONTRACT_VERSION` with an unanchored regex. `CONTRACT_VERSION` is a suffix of `MINIMUM_CONTROL_CONTRACT_VERSION`, so it survived only because v0.15.0 has no minimum and line 155 precedes 156. - `ParityGap.surface` held one id for a gap spanning four surfaces, so the xfail reason pointed readers at the wrong place; `_GAP_ROW` parsed the doc's surface column and threw it away. - The doc/code claim comparison ran one way, so the registry could advertise a claim nothing tested. - `github_action`'s roots included `scripts/github_action_outputs.py` in code and not in the doc, and nothing compared roots at all. - The placeholder-ownership guard only ran on files containing the literal `CHANGE_ME`, so a prompt naming a human-owned field without that literal was unchecked. - `_surface_files` walked the working tree while the classifier read `git ls-files`, so an untracked scratch file could fail the suite. Two guards were replaced rather than fixed. The verdict scan matched nothing anywhere in the repository; `test_surface_enumerations_match_the_engine_vocabulary` replaces it, keyed to braced set literals (a comma-joined run is often a correct partial statement) with the two overlapping vocabularies disambiguated, plus `test_vocabulary_guard_is_not_vacuous`. The contract-floor table was hand-written and covered two of the four shipped copies; it is now scanned, spelling-agnostic so the plain-JSON "runtime contract 15" that shipped in plugin.json would be caught. Every guard was replayed against the pre-fix text from `main` and catches the defect it was written for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- `test_every_scanned_claim_actually_has_rows` matched a claim only when its proving tests were exactly one name, so adding a second test to a claim would have dropped it out of the coverage check silently. - `_release` parsed an install floor with a bare `int()`, so a pre-release in `release_status.latest_release` would have crashed inside the pin scanner rather than saying what was wrong. - `_tracked_files` shelled out to `git ls-files` on every call; collection asks dozens of times. Both new registry guards were mutation-tested: a claim naming a nonexistent test, and a claim whose scan matches no file on its surface, each fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Widening the placeholder scan from CHANGE_ME-bearing files to every Markdown file on the surface fixed one hole and opened the possibility of another: the guard now runs over 48 files, and would read as green if a rewording left it with no human-owned field to look at. Seven mentions exist today; the floor is asserted, as it already is for the vocabulary guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
pengfei-threemoonslab
left a comment
There was a problem hiding this comment.
Reviewed commit dbda114d7321c2d9744bdef7104d4c01d482d4cb against c41571f2d11eb5864b487f0ace40a84dfa8f385b.
Recommend fixing the three inline findings before merge: one release/rehearsal CI regression (P1), and two false passes in the new parity guards (P2).
Validation:
python -m pytest tests/test_distribution_surface_parity.py tests/test_agent_instructions_renderers.py: 247 passed, 11 xfailed.- Ruff on both changed Python modules: passed.
./shipgate self-check --json:ready: true. - Reproduced the missing-tag failure in a fresh
--depth 1 --no-tagslocal clone withCI=true, runningtest_published_build_table_matches_the_tag. - Mutation checks through the actual guard functions accepted an extra verdict token and a newly tracked workflow with
shipgate_version: '9.9.9'. No repository source edits were made. - The PR's reported CI checks are green; the release-verification workflow is a separate path.
Agents Shipgate verification of the exact base/head: control_state=review_publishable, gate.merge_verdict=human_review_required, release_decision.decision=review_required, permissions.update_pr=true, and permissions.merge=false. Human review remains required for the trust-root changes; the static verifier result is not merge approval.
Posted as a comment review because the authenticated account is also the PR author; the recommendation is to address these findings before merge.
| assert not os.environ.get("CI"), ( | ||
| f"{tag} is not in this clone. The suite's checkout must fetch " | ||
| "full history and tags, or this corroboration silently stops " | ||
| "running." |
There was a problem hiding this comment.
[P1] Fetch tags in release verification before requiring them in all CI
This assertion applies to every GitHub Actions job, but only ci.yml's suite checkout was updated. .github/workflows/release-verify.yml:92-96 still checks out the candidate SHA with the default shallow, tagless fetch, then runs the full suite at lines 149-166. Both release.yml and release-rehearsal.yml call that workflow, so they will fail here on the missing v0.15.0 tag even though this PR's CI is green. I reproduced the failure by cloning this head with --depth 1 --no-tags and running CI=true python -m pytest tests/test_distribution_surface_parity.py::test_published_build_table_matches_the_tag. Give the release-verification test checkout the required tags/history as part of this change, and cover that checkout contract too.
There was a problem hiding this comment.
Confirmed and fixed in 96eefad. You are right that the assertion binds every CI job and only ci.yml's suite checkout was updated — the release path would have gone red on a green PR, which is the worst shape for this kind of regression.
release-verify.yml's tests job now checks out with fetch-depth: 0 and fetch-tags: true, with a comment naming the test that needs them.
On covering the contract: rather than adding the flags and hoping, tests/test_action_metadata.py::test_jobs_running_the_whole_suite_check_out_history_and_tags now asserts it for every job that runs the suite, in any workflow. It classifies a run by whether the pytest invocation has a positional path, so --ignore=tests/test_adapter_static_only.py is correctly not read as scoping the run — that distinction is what separates release-verify.yml's two pytest steps. It detects exactly ci.yml:suite and release-verify.yml:tests today, asserts the detection is non-empty so a reshaped command cannot silently empty it, and both jobs fail the assertion when their fetch flags are removed.
| PIN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( | ||
| ("action", re.compile(rf"ThreeMoonsLab/agents-shipgate@v({_VERSION})")), | ||
| ("pip", re.compile(rf"agents-shipgate==({_VERSION})")), | ||
| ("uvx", re.compile(rf"agents-shipgate@({_VERSION})")), | ||
| ) |
There was a problem hiding this comment.
[P2] Include the Action's package-version input in the pin scanner
The scanner never reads shipgate_version:, although action.yml:339-342 turns that input into pip install agents-shipgate==<value>. A newly tracked workflow under examples/github-actions/ with uses: ThreeMoonsLab/agents-shipgate@v0.15.0 and shipgate_version: '9.9.9' is selected by _pin_bearing_paths(), but unpublished_pins() returns [] and the registered parity test passes; the adopter's install then fails. The older public-surface test covers an explicit ACTION_PIN_FILES list, so it does not cover newly added workflows. Check the rendered package-version input as well as the Action ref, with a negative control that keeps the ref valid and changes only that input.
There was a problem hiding this comment.
Confirmed and fixed in 96eefad. shipgate_version: is an install pin wearing a YAML key — action.yml's install step turns it into pip install agents-shipgate==<value> — so a workflow could pass Action-ref resolution and then fail at pip install. Your point about ACTION_PIN_FILES being an explicit list is the reason it matters here: this scanner is path-derived, so it covers a workflow added later.
Added as a fourth entry in PIN_PATTERNS, with the negative control you asked for — the ref stays @v0.15.0 and only the input changes, so a scanner that read only the ref would report nothing.
I also replayed your exact reproduction: created examples/github-actions/99-review-repro.yml with that content, git added it so _surface_files (which reads git ls-files) would see it, and ran the registered test:
FAILED test_executable_pin_resolves_in_a_published_channel[examples/github-actions/99-review-repro.yml]
examples/github-actions/99-review-repro.yml pins shipgate_version input 9.9.9;
the newest published release is 0.15.0.
The repro file was removed afterwards. shipgate_version: '<NEW>' in upgrade-shipgate-version.md is not a version and stays unread; every other occurrence in the tree is already '0.15.0'.
| for match in _SET_LITERAL.finditer(text): | ||
| body = match.group(0) | ||
| named = _names(body, vocabulary) | ||
| if len(named) < 2: | ||
| continue | ||
| if _names(body, _ALL_VOCABULARY_TOKENS - set(vocabulary)): | ||
| continue # some other vocabulary's literal | ||
| literals.append(named) |
There was a problem hiding this comment.
[P2] Preserve unknown tokens when comparing a documented verdict set
_names(body, vocabulary) projects the literal onto the expected engine values before equality is checked, so an extra, unsupported value disappears. I added "needs_a_wizard" to the actual setup prompt's complete release-decision set and called test_surface_enumerations_match_the_engine_vocabulary() on that mutated text; it passed. This permits a surface to advertise a verdict the engine never emits while the registry claims exact vocabulary parity. Parse all members of a recognized set and compare that full set with the engine vocabulary; add a negative control for a complete valid set plus one unknown value (and reject mixed vocabularies rather than skipping them).
There was a problem hiding this comment.
Confirmed and fixed in 96eefad. The projection was the bug, and it was doing double duty: it also carried the "which vocabulary is this literal about" disambiguation, which is why a mixed set was skipped by both — the same fail-open from the other side, and you were right to ask for that to be rejected rather than skipped.
vocabulary_set_literals(text, vocabulary) is replaced by documented_vocabulary_sets(text), which parses all members of a candidate literal (a literal is a candidate when two or more members are engine tokens) and returns them unprojected. The test then asserts the member set is exactly MERGE_VERDICTS or exactly RELEASE_DECISIONS; anything else fails, naming both what is in neither vocabulary and what is missing from the nearest match. The projection helper is deleted rather than left around.
Negative controls now cover a complete valid set plus one unknown value, a short set, and a mixed set — plus the two that must keep passing, the complete release-decision set and the complete merge-verdict set, so the overlap on insufficient_evidence/blocked cannot make one read as a short version of the other.
Replayed your reproduction on the real prompt with needs_a_wizard added to the set:
prompts/_review_repro.md spells a verdict set as ['blocked', 'insufficient_evidence',
'needs_a_wizard', 'passed', 'review_required'], which is neither MERGE_VERDICTS … nor
RELEASE_DECISIONS …
The unmutated prompt still passes. I also re-scanned every registered surface under the new rule to be sure the stricter comparison introduces no false positive: the only candidate literals in the tree are the four copies of the setup prompt's release-decision set, all complete.
[P1] `release-verify.yml` checked out the candidate with the default shallow, tagless fetch and then ran the whole suite, which now requires the `v0.15.0` tag when `CI` is set. `release.yml` and `release-rehearsal.yml` both call it, so the release path would have gone red on a green PR — the fetch flags landed in `ci.yml` only. That checkout now asks for full history and tags, and `tests/test_action_metadata.py::test_jobs_running_the_whole_suite_check_out_history_and_tags` asserts the contract for every job that runs the suite, whichever workflow adds one next. It classifies a run by whether the pytest invocation has a positional path, so `--ignore=tests/x` does not read as a scoped run; both current suite jobs fail the assertion when their fetch flags are removed. [P2] The pin scanner never read the Action's own `shipgate_version:` input, although `action.yml` turns it into `pip install agents-shipgate==<value>`. A workflow could therefore name a valid Action ref beside a package version that was never released, and the older public-surface guard checks that key against an explicit file list which cannot cover a workflow added later. Verified by adding the reviewer's tracked example: the registered parity test now fails on it and reports `shipgate_version input 9.9.9`. [P2] The vocabulary guard projected each documented set onto the expected values before comparing, so an unsupported member vanished — `needs_a_wizard` added to the setup prompt's otherwise-complete release-decision set still compared equal. Sets are now judged by all of their members, and a literal mixing the two vocabularies fails rather than being skipped by both, which was the same fail-open from the other side. The projection helper is deleted rather than kept. All three reproductions were replayed against the fix and now fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
All three findings addressed in Each was reproduced first and replayed after the fix:
The P1 fix is deliberately not just the two flags: Full suite green, |
… module #485 and #506 landed while this branch was open, which is the first real exercise of the exemption mechanism this PR introduces. All three registered gaps flipped to XPASS, their strict markers failed, and the exemptions had to be removed to get back to green. `KNOWN_GAPS` is empty because it worked; the registry keeps a `### Closed` record of what each one was rather than dropping them silently. The merge also revealed a duplicate, which is the defect this PR exists to catch, so it is removed rather than reconciled: - `PUBLISHED_BUILDS`, `published_version()`, `contract_of()` and `floor_out_of_reach()` were this branch's copy of what #506 shipped as `agents_shipgate.published_release`. Deleted; the constants are imported. - `PIN_PATTERNS` was a copy of `test_adopter_pins_resolve.PIN_SHAPES`, down to the `shipgate_version input` shape both arrived at independently. Imported now, along with `READER_BLANK_REFS`, so `@v<NEW>` cannot read as a bad ref. - The contract-floor regex scan is deleted outright. #506 made the floor a rendered statement decided in one place, so scanning for the older prose would have been a second implementation that was also wrong. - `test_published_build_table_matches_the_tag` and the emitted-workflow checks are #506's; the registry now names its tests as the proving tests for those claims, which is what a registry is for. - `tests/fixtures/distribution_parity/ts_registertool_positive` is redundant with `samples/mcp_source_only_server`. Deleted. The Go workspace and the no-dependency negative stay: `samples/` still has no Go MCP server, and the source-text corpus does not reach the whole-workspace dependency gate. What survives is the half #506 does not reach: pins **committed** under a registered surface, found by path rather than from a list. That immediately caught `examples/github-actions/10-check-run-annotations.yml` naming `@main` — which turns out to be the one case #497's own rule allows, an explicit version incompatibility rather than an unresolvable pin. It is enumerated as a declared exception whose file must state which capability postdates the release and what to do once one carries it, so an unexplained `@main` elsewhere still fails. Workflow conflicts resolved in main's favour: `fetch-tags: true` without `fetch-depth: 0` is sufficient for the tag reads, so the generalized checkout contract now asserts that rather than the deeper fetch this branch had asked for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The claims table still named `.well-known` for `executable_pin` and `schemas.contract.CONTRACT_VERSION` for `contract_floor`. Both are now `agents_shipgate.published_release`, which is where #506 put them and what the code imports — a registry that names the wrong source of truth is the same defect as a surface that restates one. The release-channel note also still described the rendered-prompt pin gap as open. It closed; the honest-statement rule that replaced it is what the note should point at. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Rebased onto The exemption mechanism got its first real test, and it worked. All three registered gaps were waiting on exactly those two issues. Every The merge also exposed a duplicate, which is this PR's own subject, so it is removed rather than reconciled. #506 shipped What survives is the half #506's sweep does not reach: pins committed under a registered surface, found by path rather than from an explicit list — the distinction you drew in the P2 comment. One new finding, from importing your stronger ref shape. Your P1 fix and main's crossed: main added |
What this is
The first increment of #497: a registry of every surface this engine is published through, and a parity test that they agree with the engine.
docs/distribution-surfaces.mdlists all ten surfaces from the issue plus the two the first increment names (init --write --ci's emitted workflow, anddocs/design-partner-verifier-pilot.md) — what each claims, which test proves it, and where it is deliberately narrower than the CLI.tests/test_distribution_surface_parity.pyis the machine half; the two are checked against each other, so neither can be edited alone.CONTRIBUTING.md§ Surface discipline andCLAUDE.mdboth point at it.The invariant, in both halves:
The four surfaces that were disagreeing
These are repairs, not just guards.
1. The bundled setup prompt told a coding agent to write a declaration only a person may make.
add-shipgate-to-repo.mdstep 5 said to replaceagent.declared_purpose[]with "a one-line description of what the agent should do", derived from the prompt or main module. The engine's answer for that field:The prompt now separates the placeholder the agent owns (
agent.name) from the one it must surface, and quotes the engine's own wording. The Codex kit's recipes page and the design-partner runbook carried the same thing as a blanket "replace everyCHANGE_ME".2. The Claude Code kit rendered a GitHub Action tag that does not exist. Its advisory CI recipe pinned
@v{{ shipgate_version }}andshipgate_version: '{{ shipgate_version }}', soinit --agent-instructions=claude-code-skillwrote@v0.16.0andagents-shipgate==0.16.0into an adopter's CI. Neither is published. GitHub resolvesuses:before any step runs, so the adopter's first Shipgate run fails on our repository's name.It was invisible because
test_claude_code_skill_source_matches_rendererskipped that one file, so the checked-in mirror could go on sayingv0.15.0while the renderer emittedv0.16.0. Both pins now name the published release — as the Codex kit's byte-identical recipe always did — and the exemption is deleted, which is the actual repair.3. Two published surfaces demanded a contract nobody could reach.
plugins/claude-code/.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsonboth said "runtime contract 15" besidepipx install agents-shipgate, which yields contract 10. The bundled skill already states the floor it needs, so the duplicate is removed rather than re-synced.4. The design-partner runbook taught a route its own build could not run. It named
v0.15.0, demanded "runtime contract 14" (that build implements 10, and never implemented 14), floored pip at>=0.13, and then gave a read order starting atcontrol.state— whichshipgate.agent_handoff/v1does not emit at all. It now names one channel per partner (released / unqualified preview / source checkout) with the contract each implements, and states what the released build does not produce.The exemption mechanism was tested for real
#485 and #506 both landed while this was in review. All three registered gaps flipped to
XPASS, their strict markers failed, and the exemptions had to be removed to get back to green.KNOWN_GAPSis empty because the mechanism worked; the registry keeps a### Closedrecord of what each gap was rather than dropping them silently.The same merge exposed a duplicate — which is the defect this PR exists to catch, so it is removed rather than reconciled:
PUBLISHED_BUILDS,published_version(),contract_of(),floor_out_of_reach()agents_shipgate.published_releasePIN_PATTERNS(incl.shipgate_version input, arrived at independently)test_adopter_pins_resolve.PIN_SHAPES+READER_BLANK_REFScontract_floor_prose, rendered and proved against the tagtest_published_build_table_matches_the_tag, emitted-workflow checkstests/fixtures/distribution_parity/ts_registertool_positivesamples/mcp_source_only_serverWhat survives is the half #506 does not reach: pins committed under a registered surface, found by path rather than from a list, so an example or runbook added later is covered without anyone enumerating it. The Go workspace and the no-dependency negative stay in the corpus for the same reason —
samples/still has no Go MCP server, and the source-text corpus does not reach the whole-workspace dependency gate.Importing #506's stronger ref shape immediately caught
examples/github-actions/10-check-run-annotations.ymlnaming@main. That turns out to be the one case #497's own rule allows — an explicit version incompatibility rather than an unresolvable pin, and the file says so in its header. It is now an enumerated declared exception whose file must state which capability postdates the release and what to do once one carries it, so an unexplained@mainelsewhere still fails.Workflow conflicts went main's way:
fetch-tags: truewithoutfetch-depth: 0is sufficient for the tag reads, so the generalized checkout contract asserts that instead of the deeper fetch this branch asked for. The contract itself is still worth having —ci.ymlandrelease-verify.ymlwere fixed one at a time twice now, once by each side.Known gaps are rows, not omissions
A surface allowed to diverge gets a
KNOWN_GAPSentry, a row in the registry's Known parity gaps table with an owning issue, andxfail(strict=True). It fails today; the day the owning fix lands the row passes, the strict marker turns that into a failure, and the gap has to be retired. A gap cannot rot unnoticed, which is the property the previous arrangement lacked.detector-mcp-server-source— the detector answersfalsefor MCP servers the CLI acceptsemitted-workflow-unpublished-pin—init --ciemits@v<__version__>rendered-prompt-unpublished-pin— rendered prompts pinuvx agents-shipgate@<emitting build>#485's exact case is a row that fails before the fix and passes after.
tests/fixtures/distribution_parity/holds minimized TypeScript and Go MCP servers whose tool surface exists only as registration sites — the shape nosamples/fixture carries, which is why the existing parity test could not see the divergence. The CLI reports both as agent projects with onemcp_server_source; the detector reportsfalse. Shared with #485's conformance corpus.The third fixture is the load-bearing one: the TypeScript idiom spelled exactly, with no declared MCP dependency. Both implementations answer
false, and the port has to keep doing so — a proof resting on a spelling is the fail-open shape #393 named.The pin gaps use a ledger of exactly the diverging files, not a per-surface exemption. A surface-wide excuse would cover the next file to drift, which is how this became invisible;
test_unpublished_pin_ledger_is_exactmakes a new one fail loudly and a repaired one fail its own strict marker.What the review round changed
Reviewing the first commit found seven real defects, and the two most important were in the registry itself:
harnessrow named a proving test that did not exist. It had been renamed during self-review, so the document's whole product — "which test proves this claim" — was already false in the change that introduces it, and nothing noticed. Claims are now keyed to their proving tests in code,test_every_claim_names_a_test_that_existsresolves each name against this module, andtest_every_scanned_claim_actually_has_rowscatches the weaker version: a claim naming a real test whose scan matches no file on that surface. That immediately caughtdesign_partner_runbook'sexecutable_pin, which carried only a>=install floor that no pin pattern looked at — so floors are now checked too, for reachability rather than equality.github_action's roots differed between the two with nothing comparing them. Roots, claims and proving tests are now compared as sets in both directions.Also fixed: the tag corroboration read
CONTRACT_VERSIONwith an unanchored regex (CONTRACT_VERSIONis a suffix ofMINIMUM_CONTROL_CONTRACT_VERSION, so it survived only because v0.15.0 has no minimum and line 155 precedes 156);ParityGapheld one surface id for a gap spanning four, so the xfail reason pointed readers at the wrong place; the ownership guard only ran on files containing the literalCHANGE_ME; and the file walkers read the working tree while the classifier readgit ls-files.Two guards were replaced rather than repaired. The first draft's verdict scan matched nothing anywhere in the repository — a regex that finds nothing reads exactly like a regex that finds no problem.
test_surface_enumerations_match_the_engine_vocabularyreplaces it, keyed to braced set literals (a comma-joined run is usually a correct partial statement, and flagging those would force prose to be reworded for a test) with the two overlapping vocabularies disambiguated. The contract-floor table was hand-written and covered two of the four shipped copies; it is now scanned, and spelling-agnostic, so the plain-JSON "runtime contract 15" that shipped inplugin.jsonwould be caught. Both have explicit not-vacuous assertions, as does the ownership guard.Every guard was replayed against the pre-fix text from
mainand catches the defect it was written for:The two new registry guards were mutation-tested the same way.
Review findings addressed
Three findings on
dbda114d, all reproduced before fixing and replayed after:release-verify.ymlstill checked out shallow and tagless, then ran the whole suite. Requiring the tag whenCIis set landed inci.ymlonly, andrelease.yml/release-rehearsal.ymlboth callrelease-verify.yml— so the release path would have gone red on a green PR. That checkout now fetches full history and tags, andtest_jobs_running_the_whole_suite_check_out_history_and_tagsasserts the contract for every job that runs the suite, in any workflow. It classifies by whether the pytest invocation has a positional path, so--ignore=tests/xis not misread as scoping the run; both suite jobs fail it when their fetch flags are removed.shipgate_version:, whichaction.ymlturns intopip install agents-shipgate==<value>. A workflow could name a valid Action ref beside a package version that was never released. Added as a fourth pin pattern, with a negative control that keeps the ref valid and changes only the input. Replaying the reviewer's tracked example now fails:pins shipgate_version input 9.9.9.needs_a_wizardadded to the setup prompt's complete release-decision set still compared equal. Sets are now judged by all of their members, and a literal mixing the two vocabularies fails rather than being skipped by both (the same fail-open from the other side). The projection helper is deleted. Re-scanned every registered surface under the stricter rule: no false positives, the only candidate literals are the four complete copies of the release-decision set.Negative controls
Each guard has one, and they are not decorative — the first draft's verdict-vocabulary scan matched nothing anywhere in the repository and was replaced:
test_classifier_rejects_an_unregistered_top_level_entry— a new directory is unclassified.test_detector_comparison_reports_a_seeded_divergence— the comparator is not vacuously empty.test_pin_scanner_catches_a_seeded_wrong_ref— a wrong emitted Action ref fails.test_emitted_ci_workflow_check_catches_a_bad_override— through the emitter, not around it.test_contract_floor_check_catches_an_unsatisfiable_floor— on the pairing the surfaces actually shipped.test_placeholder_routing_check_catches_a_blanket_instruction— on the wording that actually shipped.test_alternation_reader_sees_a_seeded_extra_value,test_verdict_token_parser_rejects_a_seeded_extra_value.And
test_parity_corpus_covers_the_shape_that_produced_the_gapasserts the CLI side of each xfail row, so anxfailcannot be "expected-failing" for a stupid reason.Offline, with the live check where it already lives
Resolvability is judged against committed metadata:
.well-known/agents-shipgate.jsongives the source build and the published tag. Discovery and the default static path gain no network calls, andtest_resolvability_is_judged_offlinepins that by AST. The live check that the claimed tag exists on origin stays inrelease-tag-consistency— I deliberately did not add a PyPI query, because that job already records why (the index is CDN-cached and would false-fail right after a legitimate release).PUBLISHED_BUILDSrecords what each published build implements, committed so a shallow clone is deterministic, andtest_published_build_table_matches_the_tagreads it back out of the tag. Thesuitejob now checks out withfetch-depth: 0/fetch-tags: trueso that corroboration actually runs — a check that skips in CI is not a check.test_published_version_metadata_agrees_with_the_source_treeforces the table to be updated at release time.The harness renders kit templates through the package's own renderer (
_render_template), not a copy of it. A parity harness that re-implemented rendering to check for second implementations would be the joke it sounds like.Deliberately not in this PR
AGENTS_SHIPGATE_WORKFLOW_REFoverride to weigh. This PR gives it the guard.harness/gained a real check,policies/and the MCP server are recorded as claiming nothing, with why.Surface discipline
Adds no new surface. It constrains surfaces that already shipped and removes two duplicated claims (the plugin/marketplace contract number) and one test exemption. Headline metric: activation — the zero-install path and the emitted workflow are the first two things a cold evaluator runs.
Size
tests/test_distribution_surface_parity.pyis ~1,260 lines for 36 tests, which is over the reviewable-PR instinct in #493's proposal. It is one module with one job and heavy per-guard rationale; splitting it would separate each guard from its negative control. Flagging it rather than hiding it.Verification
Full suite green (exit 0) and
ruff check .clean. 11xfails, all registered gaps: 2 detector rows, 8 pin files, 1 emitted workflow.Closes #497 (first increment).
🤖 Generated with Claude Code