feat(gates): gate-83 contract-surface-shift — declaring a magic method breaks every consumer - #472
Merged
Conversation
…d breaks every consumer A method on a published contract is served either DECLARED or MAGIC (an `@method` tag routed through __call()). PHPUnit picks its mock builder on exactly that distinction — addMethods() refuses a method that exists, onlyMethods() refuses one that does not — so moving a method between the two surfaces breaks every consumer that doubles it, with no commit in their repos. Measured, not hypothetical: openregister#2498 published the ObjectService/ObjectEntity interfaces, forcing getUuid()/getRegister()/ getSchema() to be declared. opencatalogi went red on 42 errors across all 6 PHPUnit cells, decidesk on 1. Both development branches broke on commits that had passed hours earlier, and it surfaced on unrelated dependency PRs where it read as their fault. The gate is on the PRODUCER side because it cannot work anywhere else: the gates job checks out only the app and the gates package, so a consumer-side checker cannot see the real class and would resolve every external double as unknown — reporting zero for the one change that matters, which reads as a pass. Diff-scoped; fails unless the shift carries a reason-bearing `@contract-shift <announced|internal-only|new-contract> — <reason>`. Declared not-applicable when lib/Contract/ is absent, so its silence in the 18 consuming repos is stated by name rather than being a coverage gap. Recorded in UNCOVERED.md under needs-diff: the subject is a transition, so a planted/clean directory pair cannot express it. Covered instead by a suite that builds real git histories (9 assertions), and replayed against the commit that caused the outage, where it names all five shifted methods. Verified: acceptance matrix rc 0 (64 of 70 fixtured), package invariants rc 0, errexit discipline rc 0, helper suite 9 of 9.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Adds gate-83
contract-surface-shift: a diff-scoped gate that fails when a method on a published contract moves between the MAGIC surface (an@methoddocblock tag routed through__call()) and the DECLARED surface, unless the change is annotated.Why
PHPUnit picks its mock builder on exactly that distinction:
So every consumer that doubles the class has hard-coded an assumption about which surface each method sits on. Moving one breaks them with no commit in their repositories.
Measured, not hypothetical. On 2026-08-15
openregister#2498published theObjectService/ObjectEntityinterfaces, which forcedgetUuid(),getRegister()andgetSchema()to be declared:Both
developmentbranches went red on commits that had passed hours earlier, and the failures first surfaced on unrelated dependency PRs, where they read as those PRs' fault. Finding that out cost a full baseline-comparison pass across 19 repositories.Why it lives on the producer side
It cannot be written in the consuming repo. The hydra-gates job checks out exactly two things — the app and the gates package — so a consumer-side checker has no access to the real class and would resolve every external double as "unknown". It would report ZERO findings for the one change that matters, which reads as a pass. The authority for what is declared lives with the class, so the check lives there too.
The escape hatch
Categories are closed:
announced,internal-only,new-contract. Same shape as gate-16's@spec excludeand gate-61's@listener-placement. A bare tag or an unknown category fails.Verification
In a real runner run, against the commit that caused the outage:
It names all five shifted methods — the three that broke consumers plus
getOwner/getOrganisation, which nobody happened to have doubled.Negative control, a diff touching no contract file:
Named, not silent, and still counted in COVERAGE.
Suite (
scripts/lib/test_check_contract_surface_shift.sh, auto-discovered byrun-helper-suites.sh) — 9 assertions, all paired positive/negative: both shift directions, the annotation grammar, empty scope, not-applicable, unresolvable-base-fails-closed, and the terminal summary the runner greps for.One of those assertions earned its place immediately
The bare-annotation case caught a live bug in the first draft. The separator was
\s*, which matches newlines — so on a reason-less@contract-shift announcedthe match ran past the line end and captured the docblock's closing*/as the "reason". Non-empty, so the no-reason branch never fired and the escape hatch was silently open to any bare tag. Nothing else would have found it.