Files
apophis-fastify/docs/attic/root-history/FEEDBACK-cross-operation-expressiveness.md

12 KiB

FEEDBACK: Restoring Expressive Power for Cross-Operation Behavioral Contracts

From: Arbiter Team (Production user of Apophis v2.0) Date: 2026-04-26 Related: arXiv:2602.23922v1 - "Invariant-Driven Automated Testing" (Ribeiro, 2021)


Executive Summary

We have integrated Apophis v2.0 into a production Fastify application (Arbiter — 531 routes, 2,414-line monolithic server, complex OAuth 2.1 + billing + graph infrastructure). After migrating all contracts from APOSTL to Justin and attempting to write "strict" contracts for our routes, we've encountered a fundamental limitation: Justin enables us to write assertions about a single response, but it cannot express the behavioral relationships between operations that make contract testing valuable.

This feedback is informed by Ribeiro's thesis on APOSTL/PETIT, which we recently studied. The paper clarifies what we have lost in the v2.0 transition and suggests a path forward that preserves Justin's usability while restoring APOSTL's expressive power.


1. The Problem: Tautological Contracts

What We're Writing (Justin v2.0)

After migrating to Justin, our contracts look like this:

// GET /health
'x-ensures': [
  'statusCode == 200',
  'response.body.data.status == "ok"'
]

// GET /login  
'x-ensures': [
  'statusCode == 200',
  'response.body.controls.self == "/login"'
]

The problem: Every one of these assertions is already enforced by JSON Schema. statusCode == 200 is implied by the response: { 200: {...} } block. response.body.data.status == "ok" is enforced by { const: 'ok' } in the schema.

We are not testing behavior. We are redundantly asserting structure.

What We Want to Write (APOSTL-style)

Ribeiro's thesis (Chapter 4) shows the original vision: contracts express relationships between operations:

// POST /players (constructor)
// Precondition: player does not exist
response_code(GET /players/{playerNIF}) == 404

// Postcondition: player now exists
response_code(GET /players/{playerNIF}) == 200
response_body(this) == request_body(this)

This expresses a causal behavioral contract: "Creating a resource causes it to become retrievable." No JSON Schema can express this.


2. Concrete Examples from Our Codebase

Example 1: User Lifecycle (user-directory routes)

Current Justin contracts (tautological):

// POST /tenant/users
'x-ensures': [
  'statusCode != 201 || response.body.data.user_key != null',
  'statusCode != 201 || response.body.data.email != null'
]

// GET /tenant/users/:userKey
'x-ensures': [
  'statusCode != 200 || response.body.data.key != null',
  'statusCode != 200 || response.body.data.email != null'
]

What we need to express (cross-operation):

// POST /tenant/users
'x-ensures': [
  // If creation succeeded, the user must be retrievable
  'statusCode != 201 || check("GET", "/tenant/users/" + response.body.data.user_key).status == 200',
  // The retrieved user must match what we created
  'statusCode != 201 || check("GET", "/tenant/users/" + response.body.data.user_key).body.data.email == request.body.email'
]

// DELETE /tenant/users/:userKey
'x-ensures': [
  // After deletion, the user must NOT be retrievable
  'statusCode != 200 || check("GET", "/tenant/users/" + request.params.userKey).status == 404'
]

Example 2: Application Lifecycle (tenant-applications routes)

Current Justin:

// POST /tenant/applications
'x-ensures': [
  'statusCode != 201 || response.body.data.application_id != null',
  'statusCode != 201 || response.body.data.name != null'
]

What we need:

// POST /tenant/applications
'x-ensures': [
  // The created app must appear in the collection
  'statusCode != 201 || check("GET", "/tenant/applications").body.data.some(app => app.id == response.body.data.application_id)',
  // The app must be individually retrievable
  'statusCode != 201 || check("GET", "/tenant/applications/" + response.body.data.application_id).status == 200'
]

Example 3: Auth Session (auth-login routes)

What we need (not expressible in Justin at all):

// POST /auth/:tenantId/:projectId/login
'x-ensures': [
  // After login, the account endpoint must return the authenticated user
  'statusCode != 200 || check("GET", "/account", { headers: { cookie: response.headers["set-cookie"] } }).body.data.userKey != null',
  // The session cookie must be present
  'statusCode != 200 || response.headers["set-cookie"] != null'
]

Example 4: Billing Plans (billing routes)

What we need:

// POST /billing/plans
'x-ensures': [
  // Creating a plan must increment the plan count
  'statusCode != 201 || previous(check("GET", "/billing/plans").body.total_items) + 1 == check("GET", "/billing/plans").body.total_items'
]

3. What APOSTL Got Right (From the Paper)

Ribeiro's thesis (Section 4.2) states:

"APOSTL's main feature is the ability of writing logical conditions based on pure (without side-effects) API operations... APOSTL also provides an API with semantic, i.e., with these annotations one can easily understand each operation's logic."

The key capabilities we lost:

3.1 Cross-Operation References

APOSTL allowed calling GET endpoints inside pre/postconditions:

response_code(GET /players/{playerNIF}) == 404

This made it possible to verify state transitions.

3.2 Temporal Operator: previous()

response_body(this) == previous(response_body(GET /players/{playerNIF}))

This compared the state before and after an operation.

3.3 Quantifiers with Readable Syntax

for t in response_body(GET /tournaments) :-
  response_body(GET /tournaments/{t.tournamentId}/enrollments).length <=
  response_body(GET /tournaments/{t.tournamentId}/capacity)

3.4 Logical Implication

response_code(this) == 201 => response_body(this).data.ok == true

4. Why This Matters for Real-World Adoption

The Empty-Promise Problem

When we demo Apophis to stakeholders, they ask: "What can contract testing catch that unit tests can't?"

With Justin-only contracts, the honest answer is: "Not much, because we're just asserting what JSON Schema already enforces."

With cross-operation contracts, the answer becomes: "We can verify that creating a user makes them retrievable, that deleting a plan removes it from listings, that login issues a valid session — all without writing test code."

The Incentive Problem

Developers write trivial contracts because:

  1. Justin makes it easy to write statusCode == 200
  2. Justin makes it hard to express anything deeper
  3. Schema inference already covers the structural checks

The result: contracts become checkbox compliance rather than behavioral specifications.


5. Proposed Solution: Hybrid Approach

We propose a hybrid contract system that preserves Justin's familiarity while restoring APOSTL's expressive power:

5.1 Core Principle

Keep Justin for inline assertions. Add a declarative macro system for cross-operation contracts.

5.2 Proposal: x-behavior Annotations

Introduce a new annotation for behavioral contracts that are compiled to Justin + Apophis runtime calls:

// Schema-level invariant (checked after every operation)
'x-invariants': [
  'forall users in GET /tenant/users: user.email matches /^[^\s@]+@[^\s@]+\.[^\s@]+$/',
  'forall apps in GET /tenant/applications: app.tenantId == request.headers["x-tenant-id"]'
]

// Operation-level behavioral contract
app.post('/tenant/users', {
  schema: {
    'x-category': 'constructor',
    'x-ensures': ['statusCode == 201'],
    'x-behavior': [
      // Precondition: email must not exist
      'require: GET /tenant/users?q={request.body.email} returns 0 items',
      // Postcondition: created user must be retrievable
      'ensure: GET /tenant/users/{response.body.data.user_key} returns 200',
      // Postcondition: user must appear in collection
      'ensure: GET /tenant/users contains item with key == response.body.data.user_key'
    ]
  }
})

5.3 Proposal: Inline check() Function

Allow a check() helper within Justin expressions:

'x-ensures': [
  // Inline cross-operation check
  'statusCode != 201 || check({ method: "GET", url: "/tenant/users/" + response.body.data.user_key }).status == 200',
  
  // Temporal comparison
  'statusCode != 200 || check({ method: "GET", url: "/tenant/applications" }).body.total_items == previous(check({ method: "GET", url: "/tenant/applications" }).body.total_items) + 1'
]

5.4 Proposal: previous() as a Real Operator

Restore previous(expr) to evaluate expressions from the previous stateful test step:

'x-ensures': [
  // After update, the user must differ from before
  'statusCode != 200 || response.body.data.name != previous(response.body.data.name)',
  
  // After delete, the count must decrease
  'statusCode != 200 || check({ method: "GET", url: "/tenant/users" }).body.total_items == previous(check({ method: "GET", url: "/tenant/users" }).body.total_items) - 1'
]

6. Implementation Considerations

6.1 Scope Isolation

Cross-operation checks must respect Apophis scopes. If a contract calls GET /tenant/users with admin headers, the scope should propagate.

6.2 Idempotency & Side Effects

Following APOSTL's design, only GET operations should be callable from within contracts. This prevents:

  • Test cascades (one contract triggers mutations)
  • Non-deterministic failures
  • Performance degradation

6.3 Stateful Test Integration

Behavioral contracts shine in stateful testing. The previous() operator should work across the constructor→mutator→observer sequence:

// Stateful test sequence:
// 1. POST /tenant/users (constructor)
//    → captures previous (empty state)
// 2. GET /tenant/users/:key (observer)  
//    → contract: user.name == previous(request.body.name)
// 3. PUT /tenant/users/:key (mutator)
//    → contract: name changed from previous
// 4. DELETE /tenant/users/:key (destructor)
//    → contract: GET returns 404

7. Conclusion

Justin is a pragmatic choice for v2.0. It removed a 915-line parser and made Apophis accessible to JavaScript developers. But in doing so, it also removed the semantic clarity and expressive power that made contract testing valuable.

Ribeiro's thesis proves that cross-operation contracts are not just nice-to-have — they are the core value proposition of specification-driven testing. Without them, Apophis competes with JSON Schema validators. With them, Apophis enables a form of testing that no other tool provides.

We urge the Apophis team to consider a v2.1 or v3.0 that restores behavioral contract capabilities while keeping Justin's syntax for simple cases. The industry needs contracts that express "this causes that" — not just "this field equals that string."


References

  • Ribeiro, A.C.M. (2021). Invariant-Driven Automated Testing. MSc Thesis, NOVA University of Lisbon. arXiv:2602.23922v1 [cs.SE]
  • Meyer, B. (1992). Applying "Design by Contract". IEEE Computer, 25(10), 40-51.
  • Hoare, C.A.R. (1969). An Axiomatic Basis for Computer Programming. Communications of the ACM, 12(10), 576-580.

Appendix: Arbiter Route Inventory with Behavioral Contract Opportunities

Route Family Routes Missing Behavioral Contracts
user-directory 12 User lifecycle (create→get→update→delete), role changes, stats consistency
tenant-applications 10 App lifecycle, credential rotation, posture checks
auth 18 Session lifecycle (login→account→logout), token refresh, magic link redemption
billing 8 Plan/schedule lifecycle, phase transitions, invoice generation
oauth2-provider 22 Token lifecycle (issue→introspect→revoke), client registration, consent flows
graph 15 Node/edge CRUD, graph traversal consistency, query result validity

Total: ~85 routes would benefit from cross-operation behavioral contracts. Currently, 0 can express them.