Satellites prove they honour the engine's contracts by extending the
engine's abstract contract suites and running ecommerce:verify-satellite.
The command writes a JSON report. On each tag, CI signs a passing report and
the satellite's release job attaches it to the GitHub release, which is what
earns the "contract-verified" badge on the satellites page.
Parent plan §15.2.
Verification is advisory. The engine never refuses to boot an unverified satellite.
What a badge does and doesn't prove. Verification is author-attested: the satellite's own CI runs its own contract tests and signs the report with the author's own key. A valid signature proves the report came from the key the author published and was not altered since — not that an independent party re-ran the suites. Treat the badge as "the author ran the engine's contract suites against this release", and read the report itself (
namespaces,allow_empty, per-registration test counts) before trusting it for anything important.
What the verifier does
- Reads your
composer.json. Your package name, version, and the PSR-4 namespaces underautoloadtell the verifier which classes are yours. Directories underautoload-dev(ortests/) are scanned for contract tests. - Discovers your registrations. It boots your satellite through Orchestra Testbench and walks every engine registry and container binding. Every entry whose class lives in one of your namespaces is one of your registrations.
- Matches each registration to a contract test. A registration is covered by any concrete class in your tests that extends the contract's abstract suite (directly or through your own abstract base class) and mentions the implementation — its class name or its registry key as a quoted string. If you register only one implementation of a contract, every subclass of that contract's suite covers it.
- Runs the matched tests in a subprocess with your own
vendor/bin/pest(orvendor/bin/phpunit) and reads the JUnit log. - Writes the report to
.ecommerce-verify-report.jsonand exits non-zero unless the satellite is verified.
Contracts and suites
| Contract | Found in | Suite to extend |
|---|---|---|
ProductType |
ProductTypeRegistry |
ProductTypeContractTest |
PaymentGateway |
PaymentGatewayRegistry |
PaymentGatewayContractTest |
ShippingRateProvider |
ShippingRateProviderRegistry |
ShippingRateProviderContractTest |
ShippingLabelProvider |
ShippingLabelProviderRegistry |
— (no-suite) |
ShippingMethodType |
ShippingMethodTypeRegistry |
— (no-suite) |
TaxProvider |
TaxProviderRegistry |
TaxProviderContractTest |
CurrencyRateProvider |
CurrencyRateProviderRegistry |
— (no-suite) |
FraudProvider |
FraudProviderRegistry |
FraudProviderContractTest |
FulfillmentAllocationStrategy |
FulfillmentAllocationStrategyRegistry |
FulfillmentAllocationStrategyContractTest |
PromotionCondition |
PromotionConditionRegistry |
PromotionConditionContractTest |
PromotionAction |
PromotionActionRegistry |
PromotionActionContractTest |
KanbanCardWidget |
KanbanCardWidgetRegistry |
KanbanCardWidgetContractTest |
KanbanAutomationTrigger |
KanbanAutomationRegistry |
KanbanAutomationTriggerContractTest |
NotificationTemplate |
NotificationTemplateRegistry |
NotificationTemplateContractTest |
SearchProvider |
SearchProviderRegistry |
SearchProviderContractTest |
SearchIndexer |
SearchIndexerRegistry |
— (no-suite) |
CartStorage |
container binding | CartStorageContractTest |
OrderNumberGenerator |
container binding | OrderNumberGeneratorContractTest |
ReviewModerator |
container binding | ReviewModeratorContractTest |
The engine ships fifteen suites in ArtisanPackUI\Ecommerce\Testing\Contracts\
(see contracts.md for what each one
checks). A contract without a shared suite reports no-suite; that never
fails verification. The table above is
ContractSuiteMap.
Statuses
| Status | Meaning | Fails verification? |
|---|---|---|
passed |
The matched tests ran, at least one wasn't skipped, and none failed. | No |
failed |
A matched test failed or errored, every matched test was skipped, or the runner couldn't run. | Yes |
missing |
The contract has a suite, but none of your tests extends it for this implementation. | Yes |
no-suite |
The engine ships no shared suite for this contract yet. | No |
not-run |
Discovery-only run (--no-run). |
n/a |
A satellite that registers nothing is unverified unless you pass
--allow-empty (UI-only satellites).
Setup
Your satellite needs artisanpack-ui/ecommerce and
orchestra/testbench (dev). Make sure your testbench.yaml boots both the
engine's provider and yours:
providers:
- ArtisanPackUI\Ecommerce\Providers\EcommerceServiceProvider
- Acme\EcommerceShippo\ShippoServiceProvider
Add the Composer script:
{
"scripts": {
"ecommerce:verify-satellite": "ecommerce-verify-satellite"
}
}
ecommerce-verify-satellite is a Composer bin the engine ships. It runs
vendor/bin/testbench ecommerce:verify-satellite --path=<cwd> and passes
any other arguments through.
Writing contract tests
Extend the suite for each contract you implement and fill in its abstract methods:
namespace Acme\EcommerceShippo\Tests\Contracts;
use Acme\EcommerceShippo\ShippoRateProvider;
use ArtisanPackUI\Ecommerce\Contracts\ShippingRateProvider;
use ArtisanPackUI\Ecommerce\Testing\Contracts\ShippingRateProviderContractTest;
final class ShippoRateProviderContractTest extends ShippingRateProviderContractTest
{
protected function provider(): ShippingRateProvider
{
return new ShippoRateProvider( $this->fakeShippoClient() );
}
}
If you ship several implementations of the same contract, each test class
must mention the implementation it covers — by class (as above) or by
registry key (->get( 'shippo-ground' )).
Running it
composer ecommerce:verify-satellite
| Option | Purpose |
|---|---|
--path= |
Satellite root (defaults to the working directory). |
--output= |
Report path (default .ecommerce-verify-report.json, config artisanpack.ecommerce.satellites.verification.report_path). |
--package-version= |
Version to record, e.g. the git tag. Defaults to composer.json's version, else dev. |
--namespace=* |
Extra namespace prefixes that belong to the satellite. |
--tests=* |
Extra directories to scan for contract tests. |
--provider=* |
Service providers to register before discovery (if testbench.yaml doesn't). |
--no-run |
Discovery only: list registrations and matched tests. Fails only if a registration is missing. |
--allow-empty |
A satellite with no registrations counts as verified. |
--sign |
Sign a passing report with ECOMMERCE_VERIFY_SIGNING_KEY. |
--signature= |
Signature path (default verify-report.sig next to the report). |
--check-signature |
Check an existing report + signature instead of running. Uses --public-key= or ECOMMERCE_VERIFY_PUBLIC_KEY. |
--record |
Store a passing report's sha256 on the satellite's ecommerce_satellites.verified_report_hash (the satellite must be registered in the running app; see satellite-lifecycle.md). |
Add .ecommerce-verify-report.json and verify-report.sig to your
.gitignore.
Report format
{
"schema": 1,
"package": "acme/ecommerce-shippo",
"version": "v1.2.0",
"engine_version": "1.0.0",
"generated_at": "2026-09-29T12:00:00Z",
"php_version": "8.4.3",
"namespaces": [ "Acme\\EcommerceShippo\\" ],
"ran": true,
"allow_empty": false,
"runner_error": null,
"registrations": [
{
"contract": "ArtisanPackUI\\Ecommerce\\Contracts\\ShippingRateProvider",
"key": "shippo",
"class": "Acme\\EcommerceShippo\\ShippoRateProvider",
"suite": "ArtisanPackUI\\Ecommerce\\Testing\\Contracts\\ShippingRateProviderContractTest",
"test_classes": [ "Acme\\EcommerceShippo\\Tests\\Contracts\\ShippoRateProviderContractTest" ],
"status": "passed",
"tests": 8,
"assertions": 31,
"failures": 0,
"skipped": 0,
"error": null
}
],
"summary": { "registrations": 1, "passed": 1, "failed": 0, "missing": 0, "no-suite": 0, "not-run": 0 },
"verified": true
}
error explains a failed row the verifier could not even test (for
example a container binding whose class could not be determined).
namespaces lists the namespace prefixes that decided which registrations
count as the satellite's (from its composer.json, plus any --namespace
flags), and allow_empty records whether --allow-empty let a satellite
with no registrations pass. Both are part of the signed bytes.
The SHA-256 of the report's exact bytes is printed after every run. It is
the value recorded as ecommerce_satellites.verified_report_hash. A recorded hash covers
one release: when the satellite's version changes, the next registry sync
clears it until the new version is verified and recorded again.
Signing
Reports are signed with an Ed25519 detached signature (libsodium). The signature file is JSON:
{
"schema": 1,
"algorithm": "ed25519",
"report_sha256": "…",
"key_id": "…",
"public_key": "…",
"signature": "…"
}
Anyone checking a signature must use the public key they trust, not the one embedded in the file. The embedded key only tells a reader which key signed.
Generate a keypair once:
php -r '$k = sodium_crypto_sign_keypair(); echo "secret: ", base64_encode(sodium_crypto_sign_secretkey($k)), "\npublic: ", base64_encode(sodium_crypto_sign_publickey($k)), "\n";'
- Store the secret as the
ECOMMERCE_VERIFY_SIGNING_KEYrepository (or organization) secret. Never commit it. A 32-byte seed also works. - Publish the public key with your satellite's listing on the satellites page.
In CI, sign with ecommerce-sign-report [report] [signature] rather than
--sign. It reads the key from ECOMMERCE_VERIFY_SIGNING_KEY and loads only
the engine's ReportSigner (no autoloader, no service providers, no satellite
or dependency code). It refuses to sign a report whose verified flag isn't
true. --sign remains for local use.
Running the signer in a separate process is not enough on its own. On the
runner that ran composer install and your tests, a dependency or Composer
plugin could have changed vendor/bin/ecommerce-sign-report or read the key
from the environment. The reusable workflow avoids that by signing in
a separate job on a fresh runner, with a copy of the signer checked out from
the engine repository. If you build your own pipeline, do the same: give the
key only to a job that never installs or runs satellite code.
Check a signature locally:
vendor/bin/testbench ecommerce:verify-satellite --check-signature --public-key=<base64>
To check a published release, download its ecommerce-verify-report.json and
verify-report.sig and point the command at them. The signature covers the
report's bytes, not its file name:
vendor/bin/testbench ecommerce:verify-satellite --check-signature --public-key=<base64> \
--output=ecommerce-verify-report.json --signature=verify-report.sig
CI
The engine ships a reusable workflow at
.github/workflows/verify-satellite.yml
(workflow_call only, so it never runs on the engine's own pushes). Copy
stubs/workflows/verify-satellite.yml
into your satellite as .github/workflows/verify-satellite.yml:
on:
pull_request:
push:
tags:
- 'v*'
jobs:
verify:
uses: ArtisanPack-UI/ecommerce/.github/workflows/verify-satellite.yml@v1.1.0
permissions:
contents: read
with:
php-version: '8.4'
secrets:
ECOMMERCE_VERIFY_SIGNING_KEY: ${{ secrets.ECOMMERCE_VERIFY_SIGNING_KEY }}
release:
needs: verify
if: ${{ startsWith(github.ref, 'refs/tags/') && needs.verify.outputs.signed == 'true' }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: ecommerce-verify-signature
path: verification
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2.6.2
with:
files: |
verification/ecommerce-verify-report.json
verification/verify-report.sig
Attach ecommerce-verify-report.json, not .ecommerce-verify-report.json.
GitHub renames a release asset whose name starts with a dot to
default.<name>, and releases can't be changed once published. The
satellites page looks for ecommerce-verify-report.json.
It still accepts the default.ecommerce-verify-report.json that releases
made with the v1.0.0 stub carry.
Keep the reference pinned to an engine release tag (or a commit SHA). The
job passes the workflow your signing key, so never point it at a moving
branch such as @main. The reusable workflow and the stub SHA-pin every
third-party action they use; the release step runs with contents: write,
so pin it in your own release workflow too.
How the workflow runs
The reusable workflow has two jobs, so the signing key never shares a runner with your satellite's code:
-
verifyruns on every call, with no secrets and read-only permissions. It checks out your repository, runscomposer install, and runsvendor/bin/ecommerce-verify-satellite --package-version=<ref name>plusextra-args. It then uploads.ecommerce-verify-report.jsonas theecommerce-verify-reportartifact, even when verification fails. -
signruns only on tag refs, and only afterverifysucceeds. A report that isn't verified failsverify, so it's never signed. On a fresh runner,signdownloads only the report artifact and does a sparse checkout of the engine at the exact commit of the workflow file (bin/ecommerce-sign-reportandReportSigner.php, with nocomposer install). It then runsphp engine/bin/ecommerce-sign-reportwithECOMMERCE_VERIFY_SIGNING_KEY, and uploads the report andverify-report.sigas theecommerce-verify-signatureartifact. With no key passed, it logs a notice and skips signing.The artifact holds the report twice, as the same signed bytes:
ecommerce-verify-report.json, the name to attach to your release;.ecommerce-verify-report.json, kept so release steps written for v1.0.0 keep working.
The workflow's signed output is true when the
ecommerce-verify-signature artifact exists.
The workflow never creates or edits a GitHub release. Under immutable
releases, a release it created would lock before your own release step could
attach anything. Instead, the stub's release job downloads the
ecommerce-verify-signature artifact and attaches both files when it creates
the release. If you already have a release workflow, move that job's two
steps into it and make it needs: verify.
| Input | Default | Purpose |
|---|---|---|
php-version |
8.4 |
PHP version for the verify job. |
php-extensions |
dom, curl, libxml, mbstring, zip, pdo, sqlite, pdo_sqlite |
Extensions to install (intl and sodium are always added). |
working-directory |
. |
Satellite root in a monorepo. |
extra-args |
'' |
Extra verifier arguments, e.g. --allow-empty. |
upload-release-assets |
false |
Deprecated and ignored. Setting it only logs a warning. Attach the ecommerce-verify-signature artifact from your own release step instead. |
| Secret | Required | Purpose |
|---|---|---|
ECOMMERCE_VERIFY_SIGNING_KEY |
No | Base64 Ed25519 secret key. Only the sign job receives it. |
| Output | Purpose |
|---|---|
signed |
true when a signature was produced. |
Building on the verifier
All the logic lives in ArtisanPackUI\Ecommerce\Testing\Verification\:
SatelliteManifest, RegistrationDiscovery, TestClassLocator,
SuiteRunner (ProcessSuiteRunner), SatelliteVerifier,
VerificationReport, and ReportSigner. The console command is a thin
shell around SatelliteVerifier, and binding your own SuiteRunner in the
container swaps out the subprocess runner.

