What every Escalated backend has to agree on, as data rather than prose.
Eleven ports implement Escalated in eight languages. They share no source code and never will, because a Go struct is not importable into a Rails model. What they do share is behaviour: the same page names, the same props, the same rules about how inbound mail finds its ticket. This repo holds that, in a form a test can read.
Before this repo, the page manifest lived in eleven places. escalated/pages.json
was the real one, and every port vendored a copy into its own fixtures:
escalated-laravel/tests/Fixtures/escalated-pages.json
escalated-symfony/tests/Fixtures/escalated-pages.json
escalated-wordpress/tests/fixtures/escalated-pages.json
escalated-rails/spec/fixtures/escalated-pages.json
escalated-django/tests/fixtures/escalated-pages.json
escalated-go/testdata/escalated-pages.json
escalated-nestjs/test/fixtures/escalated-pages.json
escalated-phoenix/test/fixtures/escalated-pages.json
escalated-dotnet/tests/Escalated.Tests/Fixtures/escalated-pages.json
escalated-spring/src/test/resources/escalated-pages.json
Refreshing a copy was a manual step, so it did not happen. On 2026-09-20 two
had drifted: escalated-symfony was missing Escalated/Auth/TwoFactorChallenge
and Escalated/Error, and escalated-phoenix was missing Escalated/Error.
All eleven test suites were green. They were green because each port compared itself against its own copy. The test asserted "I match my fixture" and the fixture was stale, so the drift could not be seen from inside any one repo.
Reading the manifest from a versioned package instead means composer update
is what surfaces the drift.
contract/
pages.json every page name the frontend resolves, and the props each reads
schema/ JSON Schema for the manifest and for fixtures
conformance/ one rule per file, as inputs and expected result
packages/
composer/ npm/ go/ per-ecosystem accessors, each with a generated copy of contract/
runners/
php/ node/ reference runners a port copies once
scripts/
validate.py schema, cross-reference and source checks
sync.py regenerate the per-package copies
contract/ is the only directory anyone edits. Everything under packages/*/contract/
is generated by scripts/sync.py, and CI fails if a copy has drifted.
Install the package for your ecosystem, then read the manifest from it. Delete
the port's vendored escalated-pages.json at the same time, or there are two
sources of truth again.
PHP:
use Escalated\Contract\Contract;
Contract::pages(); // every page name
Contract::rendersPage('Escalated/Admin/Tickets/Index');
Contract::propsFor('Escalated/Admin/AuditLog/Index');
Contract::frontendVersion(); // the frontend release this came fromNode:
const contract = require('@escalated-dev/contract');
contract.pages();
contract.rendersPage('Escalated/Admin/Tickets/Index');
contract.propsFor('Escalated/Admin/AuditLog/Index');Go:
import contract "github.com/escalated-dev/escalated-contract/packages/go"
pages, err := contract.Pages()
ok, err := contract.RendersPage("Escalated/Admin/Tickets/Index")The files are embedded in the Go package, so nothing needs to be on disk.
runners/php and runners/node are working tests, not snippets. Copy one into
the port, change the line that points at the port's source directory, and leave
the rest alone.
A fixture is one rule, written as inputs and the result every port must produce. It describes behaviour and never an implementation, so the same file is readable from Elixir and from C#.
id: email-threading/in-reply-to-header
rule: Inbound mail resolves to a ticket by In-Reply-To before anything else.
priority: 1
source: domain-model/email-threading.md
given:
existing_ticket: { id: 42 }
inbound:
headers:
In-Reply-To: "<ticket-42@support.example.com>"
References: "<ticket-99@support.example.com>"
expect:
resolved_ticket_id: 42
matched_by: in_reply_to
why: |
References names a different ticket on purpose. If a port reads References
first it resolves to 99 and the reply lands on the wrong ticket.Each port writes one small runner that loads the fixtures, feeds given through
its own pipeline, and asserts expect. The runner lives in the port. The
fixtures live here.
Every fixture carries a source: pointing at the document in
escalated-developer-context it was taken from, and scripts/validate.py
checks that document exists. A fixture with no canonical source is a guess, and
the schema rejects it.
Fixtures marked security: true are ones where failing is a vulnerability
rather than a bug. A runner should not let those be skipped.
| Area | Fixtures | Rule |
|---|---|---|
email-threading |
7 | the five-priority inbound resolution chain, plus unresolved mail and a forged signature |
guest-policy |
2 | the default mode, and that the mode is read from settings |
admin-surfaces |
3 | workflows are event-driven, automations are time-based, macros have neither |
These three were picked because they are the ones that have been re-derived from code and got the wrong answer.
python scripts/validate.py --context ../escalated-developer-context
python scripts/sync.py
node --test runners/node/*.test.js
go vet ./...CI runs all four on every push. validate.py works without jsonschema
installed, but falls back to checking only required keys and says so.
- Write the fixture in
contract/conformance/<area>/. Give it asource:pointing at the canonical document. If there is no canonical document, write that first. python scripts/validate.pypython scripts/sync.pyand commit the regenerated copies.- Release. Ports pick it up on their next dependency bump, and fail until they implement the rule.
Step 4 is the point. A rule added here turns "six backends still owe this" from a line in a tracking document into six failing builds.
Pages come from the frontend, not from here. The order is: component into
escalated, frontend release, regenerate contract/pages.json from the
published package, contract release, then ports bump. Out of order, a port
renders a name the frontend does not have and the screen comes up blank.
escalated-developer-context— the prose these fixtures are taken fromescalated-locale— the same repo shape, for translationsescalated— the frontend thatpages.jsonis generated from