Skip to content

Repository files navigation

escalated-contract

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.

The problem this exists for

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.

What is in here

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.

Using it from a port

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 from

Node:

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.

Conformance fixtures

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.

What is covered so far

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.

Working on it

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.

Adding a rule

  1. Write the fixture in contract/conformance/<area>/. Give it a source: pointing at the canonical document. If there is no canonical document, write that first.
  2. python scripts/validate.py
  3. python scripts/sync.py and commit the regenerated copies.
  4. 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.

Adding a page

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.

Related

  • escalated-developer-context — the prose these fixtures are taken from
  • escalated-locale — the same repo shape, for translations
  • escalated — the frontend that pages.json is generated from

About

The Escalated behaviour contract: the page manifest every backend must render, and language-neutral conformance fixtures every port runs against itself.

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages