spec_to_rest
Research

Convention Engine

Edit on GitHub

Why the M1–M10 rules look the way they do: full design rationale, edge cases, override semantics

Last updated:

Research document for the spec-to-REST Convention Engine. It takes an abstract formal specification (entities, state, operations, invariants) and produces concrete infrastructure decisions: HTTP endpoints, database schemas, validation logic, error responses, and serialization formats. This document defines every rule, every edge case, and every override mechanism.

Looking for the live, code-anchored reference of how the current compiler applies these rules? See Convention Engine Reference.


Table of contents

  1. Design Philosophy
  2. The Complete Convention Ruleset
    • 2.1 HTTP Method Mapping
    • 2.2 URL Path Mapping
    • 2.3 HTTP Status Code Mapping
    • 2.4 Request/Response Body Mapping
    • 2.5 Database Schema Mapping
    • 2.6 Validation Mapping
    • 2.7 Error Response Mapping
  3. Convention Override System
  4. Worked Examples
    • 4.1 URL Shortener
    • 4.2 E-commerce Order Service
    • 4.3 Social Media Feed
  5. Edge Cases and Ambiguities
  6. Convention Profiles (Deployment Targets)
  7. Comparison with Existing Convention Systems
  8. Implementation Architecture

1. Design philosophy

The Convention Engine inherits the central insight from Alchemy (2008): state-change predicates map to write operations, and facts map to integrity constraints. We generalize this beyond databases to the full REST + DB + validation + serialization stack.

Core principles

  1. Every spec element maps to exactly one infrastructure artifact, no ambiguity, no manual wiring. An entity becomes a table, a mutation becomes a POST/PUT/DELETE, a precondition becomes a validation check, an invariant becomes a constraint.

  2. Conventions are deterministic, given the same spec, the engine always produces the same output. There is no randomness, no heuristic guessing.

  3. Conventions are overridable, every decision the engine makes can be overridden by the user via the conventions block. The engine provides sensible defaults; the user adjusts what doesn't fit.

  4. Conventions are target-agnostic in the abstract, target-specific in the concrete, the abstract mapping (operation -> HTTP method) is universal. The concrete rendering (Python class vs Go struct vs Java interface) varies by deployment profile.

  5. When in doubt, follow RFC 7231 and REST best practices, the engine does not invent novel HTTP semantics. It maps to well-understood patterns.


2. The complete convention ruleset

2.1 HTTP method mapping

The engine determines the HTTP method for each operation by analyzing its effect on state. The decision tree is evaluated top-to-bottom; the first matching rule wins.

Rule table

#ConditionHTTP MethodRationale
M1Operation mutates state AND creates a new entity (new key appears in a state relation)POSTRFC 7231: POST processes representations; creating a subordinate resource is the canonical use
M2Operation reads state, no mutation (ensures: state' = state or state is not referenced in ensures)GETRFC 7231: GET retrieves a representation; safe and idempotent
M3Operation mutates an existing entity AND every field of the entity appears in the ensures clausePUTRFC 7231: PUT replaces the current representation entirely
M4Operation mutates an existing entity AND only a subset of fields appear in the ensures clausePATCHRFC 5789: PATCH applies partial modifications
M5Operation removes an entity from state (key disappears from a state relation)DELETERFC 7231: DELETE removes the target resource
M6Operation mutates state AND creates a child entity under an existing parentPOSTPOST to the parent's sub-collection (e.g., POST /orders/{id}/items)
M7Operation reads state with complex filtering inputs (more than 3 filter parameters or structured filter object)GET with query params (preferred) or POST to a /search sub-resource if query string would exceed practical limitsPragmatic: GET is semantically correct for reads, but URL length limits exist
M8Operation has side effects but no state mutation (e.g., sends email, triggers webhook)POSTPOST is the catch-all for unsafe operations that don't fit other methods
M9Operation performs a batch mutation on multiple entities simultaneouslyPOST to a /batch sub-resourceBatch operations don't map cleanly to single-resource methods
M10Operation performs a state machine transition on an existing entityPOST to an action sub-resource (e.g., POST /orders/{id}/place)State transitions are commands, not CRUD; POST to a verb endpoint is the standard REST pattern

PUT vs PATCH disambiguation

The distinction between PUT (M3) and PATCH (M4) requires analyzing the ensures clause field coverage:

Given entity E with fields {f1, f2, f3, f4}
Given operation Op that mutates an existing E

If Op.ensures references assignments to ALL of {f1, f2, f3, f4}:
    -> PUT (full replacement)

If Op.ensures references assignments to a STRICT SUBSET of {f1, f2, f3, f4}:
    -> PATCH (partial update)

If Op.ensures references assignments to ALL fields BUT some are
  conditional (if-then in ensures):
    -> PATCH (conditional update is partial by nature)

Example:

operation UpdateUser {
  input: id: UserId, name: String, email: String, bio: String
  requires: id in users
  ensures:
    users'[id].name = name
    users'[id].email = email
    users'[id].bio = bio
    users'[id].created_at = users[id].created_at  // unchanged
}
// All fields accounted for -> PUT
operation UpdateUserEmail {
  input: id: UserId, email: String
  requires: id in users
  ensures:
    users'[id].email = email
    // other fields implicitly unchanged
}
// Only email changes -> PATCH

Operations that don't fit

Read-with-body. When an operation reads state but requires a complex structured input (e.g., a search query with nested filters, geo-bounding boxes, or full-text search parameters), the engine applies rule M7. It first attempts to flatten the input into query parameters. If the input contains nested structures, arrays of objects, or would produce a query string exceeding 2048 characters, it falls back to POST /{resource}/search with the input as the request body.

Side-effect-only operations. Operations that have side effects external to the modeled state (sending emails, triggering webhooks, publishing events) are recognized by the pattern: the ensures clause either leaves state unchanged or the mutation is to an audit/log relation. These always map to POST (rule M8).

Batch operations. Operations whose input includes a collection of entities (e.g., input: items: Set[OrderItem]) or whose ensures clause modifies multiple keys in a state relation trigger rule M9.

2.2 URL path mapping

Resource name derivation

Spec ElementURL Path SegmentRule
Entity name (singular)Pluralized, lowercased, hyphenatedOrderItem -> order-items
State relation name (if it differs from the entity)Ignored for path; entity name takes precedencestore: ShortCode -> LongURL uses short-codes not store
Operation that acts on a specific entity by ID/{resource}/{id}Input with the entity's key type -> path parameter
Operation that acts on a collection/{resource}No key-type input or input is a filter
Operation that acts on a child entity under a parent/{parent}/{parent_id}/{children}Detected when ensures clause creates/reads/deletes in a relation anchored to a parent key

Pluralization rules

The engine uses a standard English pluralization algorithm (equivalent to Rails' ActiveSupport::Inflector) with a manually curated exception table:

SingularPluralRule Applied
UserusersRegular: append -s
Categorycategories-y to -ies
Addressaddresses-s to -es
PersonpeopleIrregular
Statusstatuses-us to -uses
InventoryinventoryUncountable (no pluralization)
ShortCodeshort-codesCamelCase split + pluralize last segment

Users can override pluralization via the conventions block if the algorithm gets it wrong (e.g., conventions { ShortCode.plural = "codes" }).

Nested resource detection

The engine detects parent-child relationships from the state model:

state {
  orders: OrderId -> Order
  line_items: OrderId -> set LineItem   // LineItem is nested under Order
}

The relation line_items: OrderId -> set LineItem signals that LineItem is a child of Order because the relation key is OrderId. This produces:

  • GET /orders/{order_id}/line-items, list items for an order
  • POST /orders/{order_id}/line-items, add item to an order
  • GET /orders/{order_id}/line-items/{item_id}, get specific item
  • DELETE /orders/{order_id}/line-items/{item_id}, remove item

Nesting depth limit. The engine nests at most 2 levels deep. If a relation chain goes deeper (e.g., Order -> LineItem -> LineItemOption), the third level gets a top-level resource with a query parameter filter: GET /line-item-options?line_item_id={id} rather than GET /orders/{oid}/line-items/{lid}/options.

Path parameter extraction

An operation input becomes a path parameter when:

  1. Its type matches the key type of a state relation (e.g., input: id: OrderId where orders: OrderId -> Order), AND
  2. The operation reads, mutates, or deletes a specific entity identified by that key.

All other inputs become either query parameters (for GET) or body fields (for POST/PUT/PATCH).

Query parameter mapping for filters

For collection-read operations (GET on a plural resource), the engine inspects the operation's input fields and maps them:

Input CharacteristicMapping
Field with same name/type as an entity field?field=value (exact match filter)
Field named {entity_field}_min or {entity_field}_max?field_min=X&field_max=Y (range filter)
Field named query or search of type String?q=value (full-text search)
Field named page or offset of type Int?page=N or ?offset=N (pagination)
Field named limit or page_size of type Int?limit=N (page size, default 20, max 100)
Field named sort_by of type String?sort_by=field (sort column)
Field named sort_order or order of type Enum{asc, desc}?sort_order=asc (sort direction)

If no explicit pagination fields exist in the operation input, the engine injects default pagination parameters: page (default 1) and limit (default 20, max 100).

2.3 HTTP status code mapping

Success status codes

ConditionStatus CodeHeaders
POST that creates a new entity201 CreatedLocation: /{resource}/{new_id}
GET that returns data200 OK
PUT/PATCH that updates an entity200 OK (if response body) or 204 No Content (if no output)
DELETE that removes an entity204 No Content
POST action that triggers a side effect200 OK (if response body) or 202 Accepted (if async)
Operation with redirect semantics (overridden via conventions)302 FoundLocation: {target_url}

Error status codes from requires clauses

The engine analyzes each requires clause to determine the appropriate error status code. The analysis is pattern-based:

Requires PatternStatus CodeRationale
{key} in {state_relation} (entity existence check)404 Not FoundThe identified resource does not exist
{key} not in {state_relation} (uniqueness check)409 ConflictThe resource already exists; creating it would conflict
{field} {comparison} {value} (value validation)422 Unprocessable EntityThe input is syntactically valid JSON but semantically invalid
isValidURI(...), len(...) >= N, regex match (format validation)422 Unprocessable EntityInput fails format/structural validation
{entity}.status = {expected_state} (state machine guard)409 ConflictThe operation cannot be performed in the current state
{input1} != {input2} (relational constraint between inputs)422 Unprocessable EntityInputs are individually valid but invalid in combination
Boolean combination of the above (and, or)Use the highest priority code from the sub-clauses. Priority: 404 > 409 > 422 > 400Compound preconditions use the most specific applicable code
Priority logic for compound requires clauses

When a requires clause is a conjunction (and), the engine generates checks in order and returns the first failure. When it is a disjunction (or), the engine returns an error only if ALL disjuncts fail, using the code of the most "specific" failing clause (404 > 409 > 422).

Example:

requires:
  id in orders                           // -> 404 if fails
  and orders[id].status = "draft"        // -> 409 if fails
  and total > 0                          // -> 422 if fails

The engine generates three sequential checks. If id is not in orders, return 404. If the order exists but status is not "draft", return 409. If total is not positive, return 422.

Error status codes from invariant violations

Invariant TypeStatus CodeWhen
Entity field constraint (e.g., len(value) >= 6)422 Unprocessable EntityViolated during input validation (pre-DB)
Uniqueness constraint (detected from state: Key -> lone Value)409 ConflictViolated at DB level (duplicate key)
Cross-entity invariant (e.g., inventory >= 0 after order)409 ConflictViolated at application level (business rule)
Database CHECK constraint violation409 ConflictCaught from DB exception

2.4 Request/response body mapping

Input-to-request mapping

Input CharacteristicPlacementFormat
Key type matching a state relation (entity ID)Path parameter/{resource}/{id}
Primitive type on a GET operationQuery parameter?name=value
Complex/nested type on a GET operationQuery parameter (dot-notation) or POST /search?filter.status=active
Any type on a POST/PUT/PATCH operation (excluding path params)Request body (JSON){"field": value}
File or binary typeMultipart form data (POST/PUT only)Content-Type: multipart/form-data

Output-to-response mapping

Output CharacteristicResponse Format
Single entity{"data": {entity_fields...}, "meta": {...}}
Collection of entities{"data": [{...}, {...}], "meta": {"page": 1, "limit": 20, "total": N}}
Single scalar value{"data": value}
No output (void)Empty body, 204 No Content
Entity with nested childrenInline nested array: {"data": {"id": 1, "items": [{...}]}} up to 1 nesting level; deeper nesting returns IDs with links

Naming conventions

The engine uses snake_case for JSON field names by default (matching Python/Ruby conventions and the majority of public REST APIs). This is configurable per profile:

ProfileJSON Field ConventionRationale
python-fastapi-postgressnake_casePython ecosystem standard
go-chi-postgressnake_case (JSON) / PascalCase (Go structs)Go exports require PascalCase; JSON tags use snake_case
typescript-express-prismacamelCaseJavaScript ecosystem standard
java-spring-jpacamelCaseJava/Jackson default

Conversion between spec field names (which are snake_case in the DSL) and the target naming convention is handled by the code emitter, rather than the convention engine. The convention engine always works in snake_case internally.

Envelope format

All responses use a consistent envelope:

{
  "data": <payload>,
  "meta": {
    "request_id": "uuid",
    "timestamp": "ISO-8601"
  }
}

Error responses:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Human-readable description",
    "details": [
      {
        "field": "email",
        "constraint": "must be a valid email address",
        "value": "not-an-email"
      }
    ]
  },
  "meta": {
    "request_id": "uuid",
    "timestamp": "ISO-8601"
  }
}

The envelope is configurable. Users can override to use bare objects (no envelope), JSON:API format, or a custom structure.

2.5 Database schema mapping

Entity-to-table mapping

Spec ElementDatabase ArtifactDetails
entity Foo { ... }Table foosPluralized, snake_case
Entity field name: StringColumn name TEXTType mapping table below
Entity field age: IntColumn age INTEGER
Entity field with invariant: len(x) >= 6 and len(x) <= 10Column with CHECK (length(x) >= 6 AND length(x) <= 10)
Entity field with invariant: x matches /^[a-z]+$/Column with CHECK (x ~ '^[a-z]+$') (Postgres)Regex syntax is target-specific
No explicit primary key declaredAuto-generated id column: BIGSERIAL PRIMARY KEYConvention: every table gets a surrogate PK

Type mapping

Spec TypePostgreSQLSQLiteMySQL
StringTEXTTEXTVARCHAR(255)
String with len(x) <= NVARCHAR(N)TEXTVARCHAR(N)
IntINTEGERINTEGERINT
FloatDOUBLE PRECISIONREALDOUBLE
BoolBOOLEANINTEGERTINYINT(1)
DateTimeTIMESTAMPTZTEXT (ISO-8601)DATETIME
DateDATETEXTDATE
UUIDUUIDTEXTCHAR(36)
DecimalNUMERIC(19,4)TEXTDECIMAL(19,4)
BytesBYTEABLOBLONGBLOB

Relation-to-schema mapping

This is the most critical mapping and follows directly from Alloy's multiplicity semantics:

Relation DeclarationMultiplicitySchema ArtifactExplanation
r: A -> one BEach A maps to exactly one BColumn b_id in table as, NOT NULL REFERENCES bs(id)Mandatory foreign key, every A must have a B
r: A -> lone BEach A maps to at most one BColumn b_id in table as, REFERENCES bs(id) (nullable)Optional foreign key, A may or may not have a B
r: A -> some BEach A maps to one or more BsJunction table a_b_r(a_id REFERENCES as, b_id REFERENCES bs) with trigger or CHECK ensuring COUNT(*) >= 1 per a_idM:N with minimum cardinality 1
r: A -> set BEach A maps to zero or more BsJunction table a_b_r(a_id REFERENCES as, b_id REFERENCES bs)Standard M:N junction table
r: A -> B (no multiplicity)Treated as A -> one BSame as oneDefault multiplicity is "exactly one"

Junction table naming convention: {parent_table}_{child_table}_{relation_name}, all snake_case. Example: orders_products_line_items.

Junction table structure
CREATE TABLE orders_line_items_items (
    id           BIGSERIAL PRIMARY KEY,
    order_id     BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    line_item_id BIGINT NOT NULL REFERENCES line_items(id) ON DELETE CASCADE,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(order_id, line_item_id)  -- prevent duplicates
);

Invariant-to-constraint mapping

Invariant PatternDatabase Artifact
invariant: len(f) >= N and len(f) <= MCHECK (length(f) >= N AND length(f) <= M)
invariant: f matches /^regex$/CHECK (f ~ '^regex$') (Postgres)
invariant: f > 0CHECK (f > 0)
invariant: f in {v1, v2, v3} (enum-like)CHECK (f IN ('v1', 'v2', 'v3')) or create an ENUM type
invariant: all x in R | P(x) (global, over a relation)Trigger: BEFORE INSERT OR UPDATE that checks P for the affected row
Uniqueness detected from state: Key -> lone Value (injective)UNIQUE constraint on the value column

Automatic columns

Every table gets these columns automatically unless overridden:

ColumnTypePurpose
idBIGSERIAL PRIMARY KEYSurrogate primary key
created_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Creation timestamp
updated_atTIMESTAMPTZ NOT NULL DEFAULT NOW()Last modification (updated by trigger)

If the spec includes a deleted or removed state concept (e.g., an operation that "archives" instead of deleting), the engine adds:

ColumnTypePurpose
deleted_atTIMESTAMPTZ (nullable)Soft delete timestamp

And the DELETE endpoint sets deleted_at = NOW() instead of issuing DELETE FROM.

Index generation

The engine automatically creates indexes based on operation patterns:

PatternIndex
Operation filters by field f (appears in requires or input-to-query mapping)CREATE INDEX idx_{table}_{field} ON {table}({field})
Foreign key columnCREATE INDEX idx_{table}_{fk} ON {table}({fk}) (most databases auto-index PKs but not FKs)
Junction table columnsComposite index on (parent_id, child_id)
Field with uniqueness invariantCREATE UNIQUE INDEX instead of regular index
sort_by parameter references a fieldIndex on that field (supports efficient ORDER BY)
Full-text search operation existsGIN index on the searchable text column (Postgres-specific)

2.6 Validation mapping

Validation occurs at three layers, and the convention engine decides what goes where:

Layer 1: HTTP / request validation (before any application logic)

Spec ElementValidation CheckImplementation
Entity field type Int but input is stringType coercion / rejectionJSON Schema / Pydantic type enforcement
invariant: len(f) >= N on entity fieldString length checkJSON Schema minLength / Pydantic min_length
invariant: f matches /^regex$/Regex pattern matchJSON Schema pattern / Pydantic regex
invariant: f > 0Numeric range checkJSON Schema exclusiveMinimum: 0 / Pydantic gt=0
Required field (appears in one multiplicity or operation input without ?)Presence checkJSON Schema required array / Pydantic non-Optional field
Optional field (appears in lone multiplicity or input with ?)Allow null/absentJSON Schema without required / Pydantic Optional

Principle. Anything that can be checked without hitting the database is checked at this layer. This includes type checks, format checks, range checks, and required field checks.

Layer 2: Application / business logic validation (after parsing, before db)

Spec ElementValidation CheckImplementation
requires: id in {relation}Entity existence checkSELECT EXISTS(...) query or ORM .get()
requires: entity.status = "expected"State machine guardLoad entity, check field value
requires: input.x != input.yCross-field validationApplication code after parsing
requires: f(input) where f is a complex predicateCustom business ruleGenerated function from spec predicate
Cross-entity invariantConsistency checkTransaction-scoped query

Principle. Anything that requires reading current database state is checked at this layer. It runs inside a transaction so the check and the subsequent mutation are atomic.

Layer 3: Database constraints (last line of defense)

Spec ElementValidation CheckImplementation
Entity field invariantCHECK constraintCatches violations that somehow bypass Layer 1
UniquenessUNIQUE constraintCatches race conditions that bypass Layer 2
Foreign key existenceFOREIGN KEY constraintCatches referential integrity violations
some multiplicity minimum cardinalityTriggerEnsures at least one child exists

Principle. Database constraints are the safety net. They catch violations that bypass application-level checks (race conditions, direct DB access, bugs). The application should never rely on the DB constraint as the primary validation mechanism because DB error messages are not user-friendly.

Validation error aggregation

When multiple validation checks fail simultaneously (Layer 1), the engine aggregates all errors into a single 422 response:

{
  "error": {
    "code": "VALIDATION_FAILED",
    "message": "Request validation failed",
    "details": [
      { "field": "email", "constraint": "must match pattern ^.+@.+$", "value": "bad" },
      { "field": "age", "constraint": "must be greater than 0", "value": -5 }
    ]
  }
}

Layer 2 checks (which require DB reads) are evaluated sequentially and return on the first failure (because each check may be expensive).

2.7 Error response mapping

Structured error codes

Each requires clause generates a unique error code derived from the operation name and the clause position:

Requires ClauseGenerated Error CodeHTTP Status
requires: id in ordersORDER_NOT_FOUND404
requires: orders[id].status = "draft"ORDER_NOT_IN_EXPECTED_STATE409
requires: total > 0INVALID_TOTAL422
requires: email not in users_by_emailEMAIL_ALREADY_EXISTS409

Error code derivation rules:

  1. If the clause checks existence (x in R): {ENTITY}_NOT_FOUND
  2. If the clause checks non-existence (x not in R): {FIELD}_ALREADY_EXISTS
  3. If the clause checks state (x.status = val): {ENTITY}_NOT_IN_EXPECTED_STATE
  4. If the clause checks a value constraint: INVALID_{FIELD}
  5. If the clause is a complex expression: {OPERATION}_PRECONDITION_FAILED

Human-readable error messages

The engine generates default messages from the clause structure:

ClauseGenerated Message
id in orders"Order with the given ID was not found"
orders[id].status = "draft""Order must be in 'draft' status to perform this operation"
total > 0"Total must be greater than 0"
len(code) >= 6"Code must be at least 6 characters long"

These messages are overridable via the conventions block.


3. Convention override system

3.1 Override syntax

Overrides live in the conventions block of the spec file. Every convention decision is addressable by a dotted path:

conventions {
  // HTTP method override
  Resolve.http_method = "GET"

  // URL path override
  Resolve.http_path = "/{code}"

  // Status code override
  Resolve.http_status_success = 302

  // Custom header
  Resolve.http_header "Location" = output.url

  // DB table name override
  ShortCode.db_table = "codes"

  // DB column type override
  ShortCode.value_db_type = "VARCHAR(12)"

  // DB index override
  ShortCode.value_db_index = "unique"

  // JSON field naming convention (global)
  global.json_naming = "camelCase"    // "snake_case" | "camelCase" | "PascalCase"

  // Envelope format (global)
  global.response_envelope = "bare"   // "standard" | "bare" | "jsonapi"

  // Pagination defaults (global)
  global.pagination_default_limit = 50
  global.pagination_max_limit = 200

  // Authentication requirement
  Shorten.http_auth = "bearer"        // "none" | "bearer" | "api_key" | "basic"
  global.http_auth = "bearer"         // applied to all endpoints

  // Soft delete behavior
  Delete.http_soft_delete = true

  // Custom error message
  Resolve.requires_0_error_message = "Short code not found. It may have expired."

  // Custom error code
  Resolve.requires_0_error_code = "CODE_EXPIRED_OR_NOT_FOUND"

  // Disable auto-generated timestamp columns
  ShortCode.db_timestamps = false

  // Custom plural form
  ShortCode.plural = "codes"

  // Sort the resource name override
  Inventory.db_table = "inventory_levels"
}

3.2 Override categories

CategoryAddressable PropertiesExample
HTTP Method{Op}.http_methodShorten.http_method = "POST"
URL Path{Op}.http_pathResolve.http_path = "/go/{code}"
Status Codes{Op}.http_status_success, {Op}.http_status_{condition}Shorten.http_status_success = 200
Headers{Op}.http_header "{Name}"Resolve.http_header "Location" = output.url
Auth{Op}.http_auth, global.http_authglobal.http_auth = "bearer"
DB Table{Entity}.db_tableUser.db_table = "app_users"
DB Column{Entity}.{field}_db_type, {Entity}.{field}_db_columnUser.email_db_column = "email_address"
DB Index{Entity}.{field}_db_indexUser.email_db_index = "unique"
DB Timestamps{Entity}.db_timestampsAuditLog.db_timestamps = false
JSON Namingglobal.json_namingglobal.json_naming = "camelCase"
Envelopeglobal.response_envelopeglobal.response_envelope = "bare"
Paginationglobal.pagination_{prop}global.pagination_max_limit = 500
Pluralization{Entity}.pluralPerson.plural = "people"
Error Messages{Op}.requires_{n}_error_message
Error Codes{Op}.requires_{n}_error_code
Soft Delete{Op}.http_soft_delete, global.http_soft_deleteglobal.http_soft_delete = true

3.3 Override resolution order

When multiple overrides could apply, they are resolved in this order (most specific wins):

  1. Operation-level override (e.g., Shorten.http_method = "PUT")
  2. Entity-level override (e.g., ShortCode.db_table = "codes")
  3. Global override (e.g., global.http_auth = "bearer")
  4. Profile default (e.g., python-fastapi profile uses snake_case)
  5. Engine default (the rules in Section 2)

3.4 Override conflicts

When overrides conflict with each other:

ConflictResolution
Operation override conflicts with global overrideOperation wins
Two operation overrides for the same propertyCompilation error: "Duplicate override for {path}"
Override contradicts spec semantics (e.g., setting GET for a mutation)Warning: "Override {path} may violate REST semantics: GET should be safe"
Override sets an invalid value (e.g., Op.http_method = "INVALID")Compilation error: "Invalid HTTP method: INVALID"

3.5 What cannot be overridden

Some aspects are derived from the spec and cannot be overridden because doing so would break correctness:

PropertyWhy Not Overridable
The existence of validation checks from requires clausesRemoving a precondition check could violate spec guarantees
The existence of DB constraints from invariantsRemoving constraints could allow invalid state
The primary key column on a tableSurrogate keys are required for internal consistency
The request_id in error responsesRequired for debugging/tracing
Foreign key relationships derived from state relationsRemoving FK would break referential integrity

Users can, however, override the presentation of these (error messages, status codes, column names) without removing the underlying check.


4. Worked examples

4.1 URL shortener

Spec

service UrlShortener {

  entity ShortCode {
    value: String
    invariant: len(value) >= 6 and len(value) <= 10
    invariant: value matches /^[a-zA-Z0-9]+$/
  }

  entity LongURL {
    value: String
    invariant: isValidURI(value)
  }

  state {
    store: ShortCode -> lone LongURL
    created_at: ShortCode -> DateTime
  }

  operation Shorten {
    input:   url: LongURL
    output:  code: ShortCode, short_url: String

    requires: isValidURI(url.value)

    ensures:
      code not in pre(store)
      store'[code] = url
      short_url = base_url + "/" + code.value
      #store' = #store + 1
  }

  operation Resolve {
    input:  code: ShortCode
    output: url: LongURL

    requires: code in store

    ensures:
      url = store[code]
      store' = store
  }

  operation Delete {
    input:  code: ShortCode

    requires: code in store

    ensures:
      code not in store'
      #store' = #store - 1
  }

  operation ListAll {
    output: entries: Set[UrlMapping]

    ensures:
      entries = store
      store' = store
  }

  conventions {
    Resolve.http_status_success = 302
    Resolve.http_header "Location" = output.url
  }
}

Convention engine output: HTTP endpoints

#MethodPathStatus (Success)Status (Errors)Request BodyResponse Body
1POST/short-codes201 Created422 (invalid URL){"url": {"value": "https://..."}}{"data": {"code": {"value": "abc123"}, "short_url": "https://sho.rt/abc123"}}
2GET/short-codes/{code}302 Found404 (code not found)Empty (redirect via Location header)
3DELETE/short-codes/{code}204 No Content404 (code not found)
4GET/short-codes200 OK{"data": [{"code": {...}, "url": {...}}], "meta": {"page": 1, "limit": 20, "total": N}}
Decision trace for endpoint 1 (shorten)
  • Ensures clause adds to store -> state mutation, new key created -> Rule M1 -> POST
  • Entity is ShortCode -> pluralize -> short-codes
  • No ID in input -> collection endpoint -> /short-codes
  • Creates new entity -> 201 + Location header
Decision trace for endpoint 2 (resolve)
  • Ensures clause: store' = store -> no state mutation -> Rule M2 -> GET
  • Input is code: ShortCode which is the key type of store -> path parameter
  • Override: Resolve.http_status_success = 302 -> 302 instead of 200
  • Override: Location header set to URL value

Convention engine output: SQL DDL

-- Entity: ShortCode (primary table from the 'store' relation)
CREATE TABLE short_codes (
    id          BIGSERIAL PRIMARY KEY,
    value       VARCHAR(10) NOT NULL
                CHECK (length(value) >= 6 AND length(value) <= 10)
                CHECK (value ~ '^[a-zA-Z0-9]+$'),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_short_codes_value ON short_codes(value);

-- Relation: store (ShortCode -> lone LongURL)
-- Since 'lone' means at most one, this is a nullable FK on short_codes
-- But since the relation is the core state, we embed it in the same table:
ALTER TABLE short_codes
    ADD COLUMN long_url TEXT
    CHECK (long_url ~ '^https?://');  -- derived from isValidURI invariant

-- Metadata: created_at relation
-- Already covered by the auto-generated created_at column

-- Trigger: update updated_at on modification
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_short_codes_updated_at
    BEFORE UPDATE ON short_codes
    FOR EACH ROW
    EXECUTE FUNCTION update_updated_at();

Schema design decision. The store: ShortCode -> lone LongURL relation is embedded as a column in short_codes rather than a separate table because LongURL is a value type (single field, no identity of its own) and the multiplicity is lone (at most one). If LongURL were a full entity with its own state relations, a separate table with a foreign key would be generated instead.

Convention engine output: OpenAPI snippet

openapi: 3.1.0
info:
  title: UrlShortener API
  version: 1.0.0

paths:
  /short-codes:
    post:
      operationId: shorten
      summary: Create a new short code
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/ShortenRequest"
      responses:
        "201":
          description: Short code created
          headers:
            Location:
              schema:
                type: string
              description: URL of the created resource
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ShortenResponse"
        "422":
          description: Validation failed
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    get:
      operationId: listAll
      summary: List all short codes
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
            minimum: 1
        - name: limit
          in: query
          schema:
            type: integer
            default: 20
            minimum: 1
            maximum: 100
      responses:
        "200":
          description: List of short codes
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ListAllResponse"

  /short-codes/{code}:
    get:
      operationId: resolve
      summary: Resolve a short code to its URL
      parameters:
        - name: code
          in: path
          required: true
          schema:
            type: string
            minLength: 6
            maxLength: 10
            pattern: "^[a-zA-Z0-9]+$"
      responses:
        "302":
          description: Redirect to the long URL
          headers:
            Location:
              schema:
                type: string
              description: The original long URL
        "404":
          description: Short code not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
    delete:
      operationId: delete
      summary: Delete a short code
      parameters:
        - name: code
          in: path
          required: true
          schema:
            type: string
            minLength: 6
            maxLength: 10
            pattern: "^[a-zA-Z0-9]+$"
      responses:
        "204":
          description: Short code deleted
        "404":
          description: Short code not found
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"

components:
  schemas:
    ShortenRequest:
      type: object
      required: [url]
      properties:
        url:
          type: object
          required: [value]
          properties:
            value:
              type: string
              format: uri

    ShortenResponse:
      type: object
      properties:
        data:
          type: object
          properties:
            code:
              type: object
              properties:
                value:
                  type: string
                  minLength: 6
                  maxLength: 10
            short_url:
              type: string
              format: uri

    ListAllResponse:
      type: object
      properties:
        data:
          type: array
          items:
            type: object
            properties:
              code:
                $ref: "#/components/schemas/ShortCodeSchema"
              url:
                $ref: "#/components/schemas/LongURLSchema"
        meta:
          $ref: "#/components/schemas/PaginationMeta"

    ShortCodeSchema:
      type: object
      properties:
        value:
          type: string
          minLength: 6
          maxLength: 10
          pattern: "^[a-zA-Z0-9]+$"

    LongURLSchema:
      type: object
      properties:
        value:
          type: string
          format: uri

    PaginationMeta:
      type: object
      properties:
        page:
          type: integer
        limit:
          type: integer
        total:
          type: integer

    ErrorResponse:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
            message:
              type: string
            details:
              type: array
              items:
                type: object
                properties:
                  field:
                    type: string
                  constraint:
                    type: string
                  value: {}

Validation logic description

CheckLayerImplementation
url.value is a valid URILayer 1 (HTTP)Pydantic AnyUrl type / JSON Schema format: uri
url.value is present and not nullLayer 1 (HTTP)required in schema
code.value length 6-10Layer 1 (HTTP)Path parameter schema validation
code.value matches alphanumericLayer 1 (HTTP)Path parameter pattern validation
code in store (for Resolve, Delete)Layer 2 (App)SELECT EXISTS(SELECT 1 FROM short_codes WHERE value = ?)
code not in pre(store) (for Shorten, post-generation)Layer 2 (App)Check after generating code; retry if collision
length(value) >= 6 AND length(value) <= 10Layer 3 (DB)CHECK constraint (safety net)
value ~ '^[a-zA-Z0-9]+$'Layer 3 (DB)CHECK constraint (safety net)

4.2 E-commerce order service

Spec

service OrderService {

  entity Product {
    name: String
    price: Decimal
    sku: String
    invariant: price > 0
    invariant: len(sku) = 10
  }

  entity LineItem {
    quantity: Int
    unit_price: Decimal
    invariant: quantity > 0
    invariant: unit_price > 0
  }

  entity Order {
    status: String
    total: Decimal
    customer_email: String
    invariant: status in {"draft", "placed", "paid", "shipped", "cancelled"}
    invariant: total >= 0
    invariant: customer_email matches /^.+@.+$/
  }

  entity InventoryRecord {
    quantity_available: Int
    invariant: quantity_available >= 0
  }

  state {
    products: ProductId -> one Product
    inventory: ProductId -> one InventoryRecord
    orders: OrderId -> one Order
    line_items: OrderId -> set LineItem
    item_product: LineItem -> one Product    // each line item references a product
  }

  // --- CRUD Operations ---

  operation CreateProduct {
    input: name: String, price: Decimal, sku: String, initial_stock: Int
    output: product: Product

    requires: initial_stock >= 0

    ensures:
      product not in pre(products)
      products'[product] = Product{name, price, sku}
      inventory'[product] = InventoryRecord{initial_stock}
  }

  operation GetProduct {
    input: id: ProductId
    output: product: Product, stock: Int

    requires: id in products

    ensures:
      product = products[id]
      stock = inventory[id].quantity_available
      products' = products
  }

  operation ListProducts {
    input: name_filter?: String, min_price?: Decimal, max_price?: Decimal
    output: results: set Product

    ensures:
      results = {p in products | matches_filters(p, name_filter, min_price, max_price)}
      products' = products
  }

  // --- Order Lifecycle ---

  operation CreateOrder {
    input: customer_email: String
    output: order: Order

    requires: customer_email matches /^.+@.+$/

    ensures:
      order not in pre(orders)
      orders'[order] = Order{status: "draft", total: 0, customer_email}
      line_items'[order] = {}
  }

  operation AddLineItem {
    input: order_id: OrderId, product_id: ProductId, quantity: Int
    output: item: LineItem

    requires:
      order_id in orders
      orders[order_id].status = "draft"
      product_id in products
      quantity > 0
      inventory[product_id].quantity_available >= quantity

    ensures:
      item = LineItem{quantity, unit_price: products[product_id].price}
      line_items'[order_id] = line_items[order_id] + {item}
      item_product'[item] = products[product_id]
      orders'[order_id].total = orders[order_id].total + item.quantity * item.unit_price
  }

  operation RemoveLineItem {
    input: order_id: OrderId, item_id: LineItemId

    requires:
      order_id in orders
      orders[order_id].status = "draft"
      item_id in line_items[order_id]

    ensures:
      line_items'[order_id] = line_items[order_id] - {item_id}
      orders'[order_id].total = orders[order_id].total - item_id.quantity * item_id.unit_price
  }

  // --- State Machine Transitions ---

  operation PlaceOrder {
    input: order_id: OrderId

    requires:
      order_id in orders
      orders[order_id].status = "draft"
      #line_items[order_id] > 0      // must have at least one item

    ensures:
      orders'[order_id].status = "placed"
      // reserve inventory
      all item in line_items[order_id] |
        inventory'[item_product[item]].quantity_available =
          inventory[item_product[item]].quantity_available - item.quantity
  }

  operation PayOrder {
    input: order_id: OrderId, payment_token: String

    requires:
      order_id in orders
      orders[order_id].status = "placed"

    ensures:
      orders'[order_id].status = "paid"
  }

  operation ShipOrder {
    input: order_id: OrderId, tracking_number: String

    requires:
      order_id in orders
      orders[order_id].status = "paid"

    ensures:
      orders'[order_id].status = "shipped"
  }

  operation CancelOrder {
    input: order_id: OrderId

    requires:
      order_id in orders
      orders[order_id].status in {"draft", "placed"}

    ensures:
      orders'[order_id].status = "cancelled"
      // release inventory if was placed
      orders[order_id].status = "placed" implies
        all item in line_items[order_id] |
          inventory'[item_product[item]].quantity_available =
            inventory[item_product[item]].quantity_available + item.quantity
  }

  operation GetOrder {
    input: order_id: OrderId
    output: order: Order, items: set LineItem

    requires: order_id in orders

    ensures:
      order = orders[order_id]
      items = line_items[order_id]
      orders' = orders
  }
}

Convention engine output: HTTP endpoints

#MethodPathOperationSuccessError Codes
1POST/productsCreateProduct201422 (invalid stock/price/sku)
2GET/products/{id}GetProduct200404 (product not found)
3GET/productsListProducts200
4POST/ordersCreateOrder201422 (invalid email)
5GET/orders/{order_id}GetOrder200404 (order not found)
6POST/orders/{order_id}/line-itemsAddLineItem201404 (order/product not found), 409 (order not draft, insufficient stock), 422 (invalid quantity)
7DELETE/orders/{order_id}/line-items/{item_id}RemoveLineItem204404 (order/item not found), 409 (order not draft)
8POST/orders/{order_id}/placePlaceOrder200404 (order not found), 409 (not draft, no items)
9POST/orders/{order_id}/payPayOrder200404 (order not found), 409 (not placed)
10POST/orders/{order_id}/shipShipOrder200404 (order not found), 409 (not paid)
11POST/orders/{order_id}/cancelCancelOrder200404 (order not found), 409 (not draft/placed)
Decision trace for endpoint 6 (addlineitem)
  • Mutates state + creates new entity (LineItem) -> Rule M1 -> POST
  • LineItem is a child of Order (detected from line_items: OrderId -> set LineItem)
  • Parent is Order, child is LineItem -> nested resource
  • Path: /orders/{order_id}/line-items
  • order_id is a path parameter (key of orders relation)
  • product_id and quantity are body fields (not keys of the target resource)
Decision trace for endpoints 8-11 (state machine transitions)
  • Each mutates status field -> state mutation -> Rule M10 (state transition)
  • The operation name after removing the entity prefix gives the action verb
  • PlaceOrder -> action place -> POST /orders/{order_id}/place
  • PayOrder -> action pay -> POST /orders/{order_id}/pay
  • ShipOrder -> action ship -> POST /orders/{order_id}/ship
  • CancelOrder -> action cancel -> POST /orders/{order_id}/cancel

Convention engine output: SQL DDL

-- Entity: Product
CREATE TABLE products (
    id          BIGSERIAL PRIMARY KEY,
    name        TEXT NOT NULL,
    price       NUMERIC(19,4) NOT NULL CHECK (price > 0),
    sku         VARCHAR(10) NOT NULL CHECK (length(sku) = 10),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_products_sku ON products(sku);

-- Entity: InventoryRecord (1:1 with Product via 'inventory' relation)
CREATE TABLE inventory_records (
    id                  BIGSERIAL PRIMARY KEY,
    product_id          BIGINT NOT NULL UNIQUE REFERENCES products(id) ON DELETE CASCADE,
    quantity_available  INTEGER NOT NULL CHECK (quantity_available >= 0),
    created_at          TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_inventory_records_product_id ON inventory_records(product_id);

-- Entity: Order
CREATE TABLE orders (
    id              BIGSERIAL PRIMARY KEY,
    status          VARCHAR(20) NOT NULL DEFAULT 'draft'
                    CHECK (status IN ('draft', 'placed', 'paid', 'shipped', 'cancelled')),
    total           NUMERIC(19,4) NOT NULL DEFAULT 0 CHECK (total >= 0),
    customer_email  TEXT NOT NULL CHECK (customer_email ~ '.+@.+'),
    created_at      TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at      TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_orders_status ON orders(status);
CREATE INDEX idx_orders_customer_email ON orders(customer_email);

-- Entity: LineItem (child of Order via 'line_items' relation)
CREATE TABLE line_items (
    id          BIGSERIAL PRIMARY KEY,
    order_id    BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
    product_id  BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
    quantity    INTEGER NOT NULL CHECK (quantity > 0),
    unit_price  NUMERIC(19,4) NOT NULL CHECK (unit_price > 0),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_line_items_order_id ON line_items(order_id);
CREATE INDEX idx_line_items_product_id ON line_items(product_id);

-- updated_at trigger (applied to all tables)
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
    NEW.updated_at = NOW();
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER trg_products_updated_at BEFORE UPDATE ON products
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_inventory_records_updated_at BEFORE UPDATE ON inventory_records
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_orders_updated_at BEFORE UPDATE ON orders
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER trg_line_items_updated_at BEFORE UPDATE ON line_items
    FOR EACH ROW EXECUTE FUNCTION update_updated_at();
Schema design decisions
  • inventory: ProductId -> one InventoryRecord creates a 1:1 relationship. Since it's keyed by ProductId, the inventory_records table gets a product_id column with a UNIQUE constraint (ensuring 1:1).
  • line_items: OrderId -> set LineItem creates a 1:N relationship. Since it's set (zero or more), no minimum-cardinality trigger is needed. The line_items table gets an order_id foreign key.
  • item_product: LineItem -> one Product becomes product_id in line_items with NOT NULL (because one means exactly one).
  • The status field's enum invariant becomes a CHECK constraint with IN clause.

State machine visualization

POST /orders POST .../place POST .../cancel POST .../cancel POST .../pay POST .../ship draft placed cancelled paid shipped

4.3 Social media feed

Spec

service SocialFeed {

  entity User {
    username: String
    display_name: String
    bio: String
    invariant: len(username) >= 3 and len(username) <= 30
    invariant: username matches /^[a-zA-Z0-9_]+$/
  }

  entity Post {
    content: String
    posted_at: DateTime
    invariant: len(content) >= 1 and len(content) <= 280
  }

  entity Comment {
    content: String
    commented_at: DateTime
    invariant: len(content) >= 1 and len(content) <= 500
  }

  state {
    users: UserId -> one User
    posts: PostId -> one Post
    post_author: Post -> one User
    comments: PostId -> set Comment
    comment_author: Comment -> one User

    // M:N relations
    follows: User -> set User          // who a user follows
    likes: User -> set Post            // which posts a user liked
  }

  // --- User Operations ---

  operation CreateUser {
    input: username: String, display_name: String, bio: String
    output: user: User

    requires:
      len(username) >= 3
      // username must be unique (not already a value in users)
      all u in users | users[u].username != username

    ensures:
      user not in pre(users)
      users'[user] = User{username, display_name, bio}
  }

  operation GetUser {
    input: id: UserId
    output: user: User, follower_count: Int, following_count: Int

    requires: id in users

    ensures:
      user = users[id]
      follower_count = #{u in users | id in follows[u]}
      following_count = #follows[id]
  }

  // --- Post Operations ---

  operation CreatePost {
    input: author_id: UserId, content: String
    output: post: Post

    requires:
      author_id in users
      len(content) >= 1 and len(content) <= 280

    ensures:
      post not in pre(posts)
      posts'[post] = Post{content, posted_at: now()}
      post_author'[post] = users[author_id]
  }

  operation GetFeed {
    input: user_id: UserId, page: Int, limit: Int
    output: feed: list Post

    requires:
      user_id in users
      page >= 1
      limit >= 1 and limit <= 100

    ensures:
      feed = sorted_by_time(
        {p in posts | post_author[p] in follows[users[user_id]]},
        descending
      )
      // paginated to (page, limit)
  }

  // --- Social Graph ---

  operation Follow {
    input: follower_id: UserId, followee_id: UserId

    requires:
      follower_id in users
      followee_id in users
      follower_id != followee_id
      users[followee_id] not in follows[users[follower_id]]

    ensures:
      follows'[users[follower_id]] = follows[users[follower_id]] + {users[followee_id]}
  }

  operation Unfollow {
    input: follower_id: UserId, followee_id: UserId

    requires:
      follower_id in users
      followee_id in users
      users[followee_id] in follows[users[follower_id]]

    ensures:
      follows'[users[follower_id]] = follows[users[follower_id]] - {users[followee_id]}
  }

  // --- Likes ---

  operation LikePost {
    input: user_id: UserId, post_id: PostId

    requires:
      user_id in users
      post_id in posts
      posts[post_id] not in likes[users[user_id]]

    ensures:
      likes'[users[user_id]] = likes[users[user_id]] + {posts[post_id]}
  }

  operation UnlikePost {
    input: user_id: UserId, post_id: PostId

    requires:
      user_id in users
      post_id in posts
      posts[post_id] in likes[users[user_id]]

    ensures:
      likes'[users[user_id]] = likes[users[user_id]] - {posts[post_id]}
  }

  // --- Comments ---

  operation AddComment {
    input: post_id: PostId, author_id: UserId, content: String
    output: comment: Comment

    requires:
      post_id in posts
      author_id in users
      len(content) >= 1 and len(content) <= 500

    ensures:
      comment not in pre(comments[post_id])
      comments'[post_id] = comments[post_id] + {comment}
      comment = Comment{content, commented_at: now()}
      comment_author'[comment] = users[author_id]
  }

  operation GetComments {
    input: post_id: PostId, page: Int, limit: Int
    output: results: list Comment

    requires: post_id in posts

    ensures:
      results = sorted_by_time(comments[post_id], descending)
  }
}

Convention engine output: HTTP endpoints

#MethodPathOperationNotes
1POST/usersCreateUser
2GET/users/{id}GetUserIncludes computed follower/following counts
3POST/postsCreatePostauthor_id in body
4GET/users/{user_id}/feedGetFeedFeed is a sub-resource of User; pagination via query params
5POST/users/{follower_id}/followingFollowfollowee_id in body. Following is a sub-collection of User
6DELETE/users/{follower_id}/following/{followee_id}Unfollow
7POST/users/{user_id}/likesLikePostpost_id in body
8DELETE/users/{user_id}/likes/{post_id}UnlikePost
9POST/posts/{post_id}/commentsAddCommentauthor_id and content in body
10GET/posts/{post_id}/commentsGetCommentsPagination via query params
Decision trace for endpoint 5 (follow)
  • follows: User -> set User is a M:N self-relation on User
  • Follow mutates state (adds to follows) -> Rule M1 -> POST
  • The relation is from the follower's perspective, so the sub-resource is under the follower
  • Path: /users/{follower_id}/following (the set of users this user follows)
  • followee_id is not a path param of the target collection -> goes in body
Decision trace for endpoint 6 (unfollow)
  • Removes from follows -> Rule M5 -> DELETE
  • Targets a specific follow relationship: /users/{follower_id}/following/{followee_id}
Decision trace for endpoint 4 (getfeed)
  • Reads state, no mutation -> Rule M2 -> GET
  • Feed is conceptually a sub-resource of a user (their personalized feed)
  • Path: /users/{user_id}/feed
  • page and limit are pagination params -> query parameters

Convention engine output: SQL DDL (junction tables)

-- Entity: User
CREATE TABLE users (
    id            BIGSERIAL PRIMARY KEY,
    username      VARCHAR(30) NOT NULL
                  CHECK (length(username) >= 3 AND length(username) <= 30)
                  CHECK (username ~ '^[a-zA-Z0-9_]+$'),
    display_name  TEXT NOT NULL,
    bio           TEXT NOT NULL DEFAULT '',
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX idx_users_username ON users(username);

-- Entity: Post
CREATE TABLE posts (
    id          BIGSERIAL PRIMARY KEY,
    author_id   BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    content     VARCHAR(280) NOT NULL
                CHECK (length(content) >= 1 AND length(content) <= 280),
    posted_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_posts_author_id ON posts(author_id);
CREATE INDEX idx_posts_posted_at ON posts(posted_at DESC);

-- Entity: Comment
CREATE TABLE comments (
    id            BIGSERIAL PRIMARY KEY,
    post_id       BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    author_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    content       VARCHAR(500) NOT NULL
                  CHECK (length(content) >= 1 AND length(content) <= 500),
    commented_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    created_at    TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_comments_post_id ON comments(post_id);
CREATE INDEX idx_comments_author_id ON comments(author_id);
CREATE INDEX idx_comments_commented_at ON comments(commented_at DESC);

-- Junction Table: follows (User -> set User, M:N self-relation)
CREATE TABLE user_follows (
    id           BIGSERIAL PRIMARY KEY,
    follower_id  BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    followee_id  BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    created_at   TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    CHECK (follower_id != followee_id),  -- from requires: follower_id != followee_id
    UNIQUE(follower_id, followee_id)
);

CREATE INDEX idx_user_follows_follower_id ON user_follows(follower_id);
CREATE INDEX idx_user_follows_followee_id ON user_follows(followee_id);

-- Junction Table: likes (User -> set Post, M:N)
CREATE TABLE user_post_likes (
    id          BIGSERIAL PRIMARY KEY,
    user_id     BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    post_id     BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    UNIQUE(user_id, post_id)
);

CREATE INDEX idx_user_post_likes_user_id ON user_post_likes(user_id);
CREATE INDEX idx_user_post_likes_post_id ON user_post_likes(post_id);
Junction table design decisions
  • follows: User -> set User is a M:N self-relation. The junction table user_follows has follower_id and followee_id both referencing users(id). The CHECK (follower_id != followee_id) constraint comes from the requires: follower_id != followee_id clause in the Follow operation, the engine promotes operation-level preconditions to DB constraints when they are universally applicable.

  • likes: User -> set Post is a standard M:N relation. The junction table user_post_likes has a UNIQUE constraint on (user_id, post_id) because the spec's requires clause for LikePost includes posts[post_id] not in likes[users[user_id]], which means duplicates are not allowed.

  • Both junction tables get individual indexes on each foreign key column to support efficient lookups in both directions (e.g., "who follows this user" and "who does this user follow").

Pagination convention

For GetFeed and GetComments, the engine generates:

GET /users/{user_id}/feed?page=1&limit=20

Response:
{
  "data": [
    {"id": 42, "content": "Hello world", "posted_at": "2026-04-05T10:30:00Z", ...},
    ...
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "total": 1523,
    "has_next": true,
    "has_prev": false
  }
}

The engine adds has_next and has_prev boolean fields to the pagination metadata based on the current page and total count. This allows clients to implement forward/ backward pagination without computing it themselves.

For cursor-based pagination (when the spec uses sorted_by_time or similar ordered access patterns), the engine can optionally generate cursor-based endpoints:

GET /users/{user_id}/feed?after=eyJpZCI6NDJ9&limit=20

This is activated via global.pagination.strategy = "cursor" in the conventions block.


5. Edge cases and ambiguities

5.1 Operations that both read AND write

Example: "Increment counter and return new value"

operation IncrementViewCount {
  input: code: ShortCode
  output: new_count: Int

  requires: code in store

  ensures:
    view_count'[code] = view_count[code] + 1
    new_count = view_count'[code]
    store' = store
}

Resolution. This mutates state (view_count' differs from view_count) AND returns data. The engine maps this to POST because the operation is not safe (it has side effects). GET would violate RFC 7231's safety requirement.

Rule. If an operation mutates ANY state relation, it is not a GET, regardless of whether it also returns data. The method is determined by the mutation rules (M1-M6, M8-M10).

5.2 Operations with multiple entity inputs

Example: "Transfer between accounts"

operation Transfer {
  input: from_id: AccountId, to_id: AccountId, amount: Decimal

  requires:
    from_id in accounts
    to_id in accounts
    from_id != to_id
    accounts[from_id].balance >= amount
    amount > 0

  ensures:
    accounts'[from_id].balance = accounts[from_id].balance - amount
    accounts'[to_id].balance = accounts[to_id].balance + amount
}

Resolution. This mutates existing entities but doesn't clearly belong to one resource. The engine applies Rule M8 (side-effect POST) and creates a top-level action endpoint:

  • POST /transfers with body {"from_id": 1, "to_id": 2, "amount": 100.00}

The entity to use for the path is the operation name itself, treated as a verb-noun (Transfer -> transfers). Neither from_id nor to_id becomes a path parameter because neither uniquely identifies the target resource.

Rule. When an operation takes multiple entity IDs as input and mutates both, the operation name becomes the resource name and all IDs go in the request body.

5.3 Operations that create child entities

Example: "Add item to order"

As shown in Example 4.2, the engine detects parent-child relationships from the state model and routes child creation to the parent's sub-collection:

  • POST /orders/{order_id}/line-items (NOT POST /line-items)

Rule. If entity B is a child of entity A (detected from r: AId -> set B in the state model), then creating B routes to POST /{A_plural}/{a_id}/{B_plural}.

Exception. If B also exists independently (has its own top-level read operations not scoped to a parent), the engine generates BOTH:

  • POST /orders/{order_id}/line-items (create under parent)
  • GET /line-items/{id} (direct access by ID)

5.4 Idempotency

HTTP MethodIdempotent?Engine Behavior
GETYes (by definition)No special handling
PUTYes (by definition)No special handling, full replacement is inherently idempotent
DELETEYes (by definition)Return 204 even if already deleted (do not return 404 on re-delete)
PATCHNot necessarilyNo special handling
POST (create)Not idempotentEngine generates an Idempotency-Key header option: clients can send a UUID, server deduplicates
POST (action)Not idempotentSame Idempotency-Key mechanism

The Idempotency-Key mechanism is opt-in. The engine generates the infrastructure (a table to store idempotency keys and their responses) but only activates it when the conventions block enables it:

conventions {
  global.http_idempotency_key = true
}

When enabled, the engine generates:

CREATE TABLE idempotency_keys (
    key         UUID PRIMARY KEY,
    endpoint    TEXT NOT NULL,
    request_hash TEXT NOT NULL,
    response_status INTEGER NOT NULL,
    response_body JSONB,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT NOW(),
    expires_at  TIMESTAMPTZ NOT NULL DEFAULT NOW() + INTERVAL '24 hours'
);

5.5 Optional vs required inputs

Input DeclarationTreatment
input: name: StringRequired (422 if absent)
input: name?: StringOptional (null/absent is valid)
input: name: String = "default"Optional with default value

For GET operations, optional inputs become optional query parameters. For POST/PUT/PATCH, they become optional body fields (absent or null).

5.6 Collection returns: Pagination, filtering, sorting

When an operation returns a collection (output: results: set Entity or list Entity):

  1. Pagination is always injected (default: page-based with page=1, limit=20)
  2. Filtering is derived from the operation's input fields that match entity field names
  3. Sorting is injected if the operation's ensures clause references ordering (e.g., sorted_by_time)

The engine generates query parameter documentation in the OpenAPI spec:

parameters:
  - name: page
    in: query
    schema: { type: integer, default: 1, minimum: 1 }
  - name: limit
    in: query
    schema: { type: integer, default: 20, minimum: 1, maximum: 100 }
  - name: sort_by
    in: query
    schema: { type: string, enum: [created_at, name, price] }
  - name: sort_order
    in: query
    schema: { type: string, enum: [asc, desc], default: desc }

5.7 File uploads and binary data

If an entity has a Bytes field or the spec mentions file/binary data:

entity Attachment {
  filename: String
  content_type: String
  data: Bytes
}

The engine maps creation to a multipart/form-data endpoint:

POST /attachments
Content-Type: multipart/form-data

--boundary
Content-Disposition: form-data; name="filename"
document.pdf
--boundary
Content-Disposition: form-data; name="content_type"
application/pdf
--boundary
Content-Disposition: form-data; name="data"; filename="document.pdf"
Content-Type: application/octet-stream
<binary data>
--boundary--

The database stores the binary data as BYTEA (Postgres) or stores a file path if global.storage.binary = "filesystem" is set in conventions.

5.8 Async operations and long-running tasks

If an operation's ensures clause references eventual consistency or the operation is annotated as async:

operation ProcessLargeImport {
  input: file: Bytes
  output: job_id: JobId

  requires: len(file) > 0

  ensures:
    // async: result available later
    job_id not in pre(jobs)
    jobs'[job_id].status = "pending"
}

The engine generates:

  1. POST /imports -> 202 Accepted, body: {"data": {"job_id": "uuid"}}
  2. GET /imports/{job_id} -> 200 OK, body: {"data": {"status": "pending"|"running"|"completed"|"failed", "result": ...}}

The 202 status code signals that the request has been accepted but processing is not complete. The engine generates a polling endpoint automatically.

5.9 Webhooks

If the spec mentions notification or callback patterns:

conventions {
  PlaceOrder.http_webhook = "order.placed"
}

The engine generates:

  1. A webhook registration endpoint: POST /webhooks with {"url": "...", "events": ["order.placed"]}
  2. A webhook delivery table in the DB
  3. Async delivery logic that POSTs to registered URLs after the operation completes

6. Convention profiles (deployment targets)

6.1 python-fastapi-postgres

Stack. FastAPI + SQLAlchemy (async) + PostgreSQL + Pydantic + Alembic

AspectImplementation
Entity -> ModelSQLAlchemy DeclarativeBase class with Mapped[] type annotations
ValidationPydantic BaseModel with Field() validators; invariants become field_validator decorators
DB AccessSQLAlchemy async sessions; each endpoint gets a Depends(get_db)
RoutingFastAPI APIRouter with typed path/query params and auto-OpenAPI
Error HandlingCustom HTTPException subclasses; @app.exception_handler for structured errors
MigrationsAlembic env.py + generated migration files from DDL
JSON namingsnake_case (native)
AsyncFull async/await; asyncpg driver

Example generated code structure:

generated/
  app/
    main.py                   # FastAPI app, middleware, exception handlers
    models/
      short_code.py           # SQLAlchemy model
    schemas/
      short_code.py           # Pydantic request/response schemas
    routers/
      short_codes.py          # FastAPI router with endpoint functions
    services/
      short_code_service.py   # Business logic (requires checks, state transitions)
    db/
      session.py              # Async DB session factory
      migrations/
        versions/
          001_initial.py      # Alembic migration
    tests/
      test_short_codes.py     # Pytest + httpx async tests
      conftest.py             # Fixtures (test DB, client)

Validation example:

from pydantic import BaseModel, Field, field_validator

class ShortenRequest(BaseModel):
    url: str = Field(..., description="The long URL to shorten")

    @field_validator('url')
    @classmethod
    def validate_url(cls, v: str) -> str:
        # Generated from: invariant: isValidURI(value)
        from urllib.parse import urlparse
        result = urlparse(v)
        if not all([result.scheme, result.netloc]):
            raise ValueError('Must be a valid URI')
        return v

Error handling example:

from fastapi import HTTPException

class NotFoundError(HTTPException):
    def __init__(self, entity: str, id: str):
        super().__init__(
            status_code=404,
            detail={
                "error": {
                    "code": f"{entity.upper()}_NOT_FOUND",
                    "message": f"{entity} with the given ID was not found",
                }
            }
        )

class ConflictError(HTTPException):
    def __init__(self, code: str, message: str):
        super().__init__(
            status_code=409,
            detail={"error": {"code": code, "message": message}}
        )

6.2 go-chi-postgres

Stack. Go chi router + sqlc + pgx + PostgreSQL

AspectImplementation
Entity -> ModelGo struct with json and db tags
Validationgo-playground/validator struct tags + custom validation functions
DB Accesssqlc-generated type-safe query functions from SQL
Routingchi Router with middleware stack
Error HandlingCustom error types implementing error interface; middleware renders JSON
Migrationsgolang-migrate .sql files
JSON namingsnake_case via json:"field_name" struct tags
AsyncGoroutines for background tasks; context.Context for cancellation

Example generated code structure:

generated/
  cmd/
    server/
      main.go                 # HTTP server setup, dependency injection
  internal/
    models/
      models.go               # Go structs for entities
    handlers/
      short_codes.go          # HTTP handler functions
    services/
      short_code_service.go   # Business logic
    db/
      queries.sql             # SQL queries (input for sqlc)
      sqlc.yaml               # sqlc configuration
      query.sql.go            # sqlc-generated Go code (DO NOT EDIT)
      migrations/
        001_initial.up.sql
        001_initial.down.sql
    middleware/
      error.go                # Error rendering middleware
      validate.go             # Request validation middleware
  go.mod
  go.sum

Validation example:

type ShortenRequest struct {
    URL string `json:"url" validate:"required,url"`
}

type ShortCode struct {
    Value string `json:"value" validate:"required,min=6,max=10,alphanum"`
}

Error handling example:

type AppError struct {
    Code    string `json:"code"`
    Message string `json:"message"`
    Status  int    `json:"-"`
}

func (e *AppError) Error() string { return e.Message }

var ErrCodeNotFound = &AppError{
    Code:    "SHORT_CODE_NOT_FOUND",
    Message: "Short code not found",
    Status:  http.StatusNotFound,
}

6.3 typescript-express-prisma

Stack. Express.js + Prisma ORM + PostgreSQL + Zod

AspectImplementation
Entity -> ModelPrisma schema model declarations
ValidationZod schemas with .parse() / .safeParse()
DB AccessPrisma Client with typed queries
RoutingExpress Router with middleware
Error HandlingCustom error classes extending Error; Express error middleware
MigrationsPrisma Migrate
JSON namingcamelCase (JavaScript convention)
Asyncasync/await with Express async handler wrapper

Example generated code structure:

generated/
  src/
    index.ts                    # Express app setup
    prisma/
      schema.prisma             # Prisma schema
      migrations/
        001_initial/
          migration.sql
    models/
      shortCode.ts              # TypeScript interfaces
    schemas/
      shortCode.schema.ts       # Zod validation schemas
    routes/
      shortCodes.ts             # Express routes
    services/
      shortCodeService.ts       # Business logic
    middleware/
      errorHandler.ts           # Global error handler
      validate.ts               # Zod validation middleware
    errors/
      index.ts                  # Custom error classes
  package.json
  tsconfig.json

Validation example:

import { z } from "zod";

export const ShortenRequestSchema = z.object({
  url: z.string().url("Must be a valid URI"),
});

export const ShortCodeParamSchema = z.object({
  code: z
    .string()
    .min(6, "Code must be at least 6 characters")
    .max(10, "Code must be at most 10 characters")
    .regex(/^[a-zA-Z0-9]+$/, "Code must be alphanumeric"),
});

6.4 java-spring-jpa

Stack. Spring Boot + Spring Data JPA + Hibernate + PostgreSQL + Bean Validation

AspectImplementation
Entity -> ModelJPA @Entity class with @Column annotations
ValidationBean Validation annotations (@NotNull, @Size, @Pattern) + custom validators
DB AccessSpring Data JPA Repository interfaces with derived query methods
Routing@RestController with @RequestMapping, @GetMapping, etc.
Error Handling@ControllerAdvice with @ExceptionHandler methods
MigrationsFlyway .sql migration files
JSON namingcamelCase (Jackson default)
Async@Async for background tasks; CompletableFuture return types

Example generated code structure:

generated/
  src/main/java/com/example/urlshortener/
    UrlShortenerApplication.java
    model/
      ShortCode.java                # JPA entity
    dto/
      ShortenRequest.java           # Request DTO with Bean Validation
      ShortenResponse.java          # Response DTO
    repository/
      ShortCodeRepository.java      # Spring Data JPA interface
    service/
      ShortCodeService.java         # Business logic
    controller/
      ShortCodeController.java      # REST controller
    exception/
      GlobalExceptionHandler.java   # @ControllerAdvice
      ResourceNotFoundException.java
      ConflictException.java
  src/main/resources/
    application.yml
    db/migration/
      V1__initial.sql               # Flyway migration
  pom.xml

Validation example:

public class ShortenRequest {
    @NotNull
    @URL(message = "Must be a valid URI")
    private String url;
}

@Entity
@Table(name = "short_codes")
public class ShortCode {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 10)
    @Size(min = 6, max = 10)
    @Pattern(regexp = "^[a-zA-Z0-9]+$")
    private String value;
}

7. Comparison with existing convention systems

7.1 Ruby on Rails (convention over configuration)

What Rails does

  • Model class Order maps to table orders (pluralized, snake_case)
  • has_many :line_items generates FK-based association
  • resources :orders generates 7 RESTful routes (index, show, new, create, edit, update, destroy)
  • Validation via validates :name, presence: true, length: { maximum: 255 }
  • Callbacks (before_save, after_create) for side effects

What we learn

  • Pluralization and naming conventions are proven and well-understood; we adopt them directly
  • The 7 standard REST actions (index/show/create/update/destroy + new/edit forms) cover most CRUD; we generate the same set
  • Rails' approach to "member routes" for custom actions (resources :orders do; member do; post :place; end; end) maps directly to our state machine transition endpoints

What we do differently

  • Rails has no notion of preconditions or postconditions; its validations are structural only
  • We derive routes from spec semantics (mutation analysis), rather than from explicit resources declarations
  • We generate the database schema AND the application code from a single spec; Rails requires you to write migrations separately from models
  • Our invariants become CHECK constraints AND application validation AND tests; Rails validations are application-only

7.2 JHipster JDL (entity-to-Spring-boot)

What JHipster does

  • JDL entity definitions -> Spring Boot entities, repositories, services, REST controllers, Angular/React UI
  • Relationships (OneToMany, ManyToMany) -> JPA annotations + FK/junction tables
  • Pagination, filtering, sorting generated automatically
  • DTOs, mappers, and service layer generated

What we learn

  • JHipster proves that a full stack can be generated from a structural spec; this validates our approach
  • JHipster's relationship types directly correspond to our multiplicity annotations
  • JHipster's DTO/mapper pattern is a good model for separating API schemas from DB models

What we do differently

  • JHipster has no behavioral specs, no preconditions, postconditions, or invariants. All generated endpoints accept any valid-shaped request regardless of business rules
  • JHipster generates CRUD only; custom operations (state machines, transfers, computations) must be hand-coded
  • We generate from a formal spec that can be model-checked BEFORE code generation; JHipster cannot detect spec inconsistencies
  • We generate conformance tests from the spec; JHipster generates basic unit tests but no property-based tests

7.3 Django REST framework (model -> serializer -> viewset -> router)

What DRF does

  • ModelSerializer auto-generates serialization from Django model fields
  • ModelViewSet generates list/create/retrieve/update/partial_update/destroy actions
  • DefaultRouter maps ViewSet actions to URLs
  • Permission classes, throttling, filtering, pagination are composable

What we learn

  • DRF's layered architecture (model -> serializer -> viewset -> router) is a clean separation of concerns
  • DRF's @action decorator for custom endpoints maps to our state machine transition routes
  • DRF's filter backends (django-filter) show how to auto-generate filtering from model fields

What we do differently

  • DRF requires you to write the Django model first; we generate the model from the spec
  • DRF has no formal validation beyond field types; we generate validation from invariants and preconditions
  • DRF's viewsets are CRUD-centric; our convention engine handles arbitrary operations including state machines and multi-entity mutations

7.4 Smithy traits (protocol-agnostic behavior)

What Smithy does

  • Traits annotate shapes with behavioral metadata (@http, @auth, @paginated, @idempotent)
  • Protocol-agnostic: same Smithy model can target REST, gRPC, MQTT
  • Code generators produce client SDKs and server stubs
  • Used for all AWS SDK APIs

What we learn

  • Smithy's trait system is the best model for extensible metadata; our conventions block is directly inspired by Smithy traits
  • Smithy's @readonly, @idempotent traits correspond to our mutation analysis (we infer these instead of requiring annotations)
  • Smithy's resource-based modeling (resource with CRUD lifecycle) matches our entity concept

What we do differently

  • Smithy models API structure, rather than behavior; you describe WHAT the API looks like, not WHAT it does. We describe what the API does (pre/postconditions) and derive what it looks like
  • Smithy requires manual annotation of every trait; we infer most traits from spec semantics
  • Smithy does not generate database schemas or validation logic; it focuses on API surface
  • We can verify spec consistency; Smithy validates structural correctness but not behavioral correctness

7.5 OpenAPI code generators

What they do

  • Take an OpenAPI YAML/JSON spec and generate client SDKs and server stubs in 40+ languages
  • Mature, widely-used ecosystem

What we learn

  • OpenAPI is the lingua franca of REST API description; we should generate OpenAPI as an intermediate artifact
  • The quality of generated server stubs varies wildly; we should not depend on third-party generators for production code

What we do differently

  • OpenAPI describes the API surface (paths, methods, schemas) but not the implementation; we generate both
  • OpenAPI has no notion of state, operations, or invariants; it describes the HTTP interface only
  • Our spec is upstream of OpenAPI; we generate OpenAPI from our spec as one of many artifacts
  • We generate tests that verify the implementation matches the spec; OpenAPI generators produce no behavioral tests

7.6 Summary comparison table

FeatureRailsJHipsterDRFSmithyOpenAPI-GenOur Engine
Derives routes from specNo (explicit)No (explicit)No (explicit)Partial (traits)No (input IS routes)Yes (from mutation analysis)
Generates DB schemaNo (manual migrations)Yes (from JDL)No (manual models)NoNoYes (from state model)
Behavioral validationNoNoNoNoNoYes (from requires/ensures)
Formal verificationNoNoNoStructural onlyStructural onlyYes (model checking)
Test generationBasicBasicBasicContract testsNoProperty-based from spec
Custom operationsManualManual@action decoratorCustom shapesManualFrom spec operations
State machinesManualManualManualNot modeledNot modeledAuto-detected from transitions
Multi-targetRuby onlyJava/Spring onlyPython only7+ languages40+ (variable quality)4 targets (high quality)

8. Implementation architecture

8.1 Internal structure

The convention engine is a pure function:

ConventionEngine(spec_ir: SpecIR, profile: Profile, overrides: Overrides) -> ConventionOutput

It has no side effects, no I/O, no database access. It takes a parsed spec (IR), a deployment profile, and user overrides, and returns a complete set of infrastructure decisions.

Convention Engine Spec IR Profile Overrides ConventionOutput HTTP endpoints DB schema Validation logic Error mappings OpenAPI spec P1 Phase 2: Op AnalysisClassify mutations, detect state transitions,identify parent-child ops Phase 3: Rule ApplicationHTTP method, URL path, status code,DB schema, validation rules Phase 4: Override MergeApply user overrides, profile defaults,validate consistency

8.2 Phase 1: Entity analysis

The entity analyzer classifies each entity in the spec:

ClassificationCriteriaExample
Root entityHas its own key in a state relation, no parent FKOrder, User, Product
Child entityReferenced via ParentId -> set Child relationLineItem (child of Order)
Value typeSingle field, no own state relations, used inlineShortCode, LongURL
Junction entityOnly exists to link two other entities (M:N)Auto-detected from set relations

It also builds a relationship graph (the live IR shape is extracted from Isabelle to modules/ir/src/main/scala/specrest/ir/generated/SpecRestGenerated.scala with the *Full case classes; sketch below uses English field names for readability, the extracted positional shape uses a/b/c/…):

enum EntityClassification derives CanEqual:
  case Root, Child, Value, Junction

final case class EntityInfo(
    name: String,
    classification: EntityClassification,
    fields: List[FieldDecl],
    invariants: List[Expr],
    parent: Option[String],            // for child entities
    children: List[String],            // for root entities
    relations: List[RelationInfo]      // all relations involving this entity
)

8.3 Phase 2: Operation analysis

The operation analyzer classifies each operation. The shipped types live in modules/convention/src/main/scala/specrest/convention/Types.scala:

enum OperationKind derives CanEqual:
  case Create, Read, Replace, PartialUpdate, Delete, CreateChild,
       FilteredRead, SideEffect, BatchMutation, Transition

final case class AnalysisSignals(
    mutatedRelations: List[String],
    preservedRelations: List[String],
    createsNewKey: Boolean,
    deletesKey: Boolean,
    targetEntityFieldCount: Option[Int],
    withFieldCount: Option[Int],
    filterParamCount: Int,
    isTransition: Boolean,
    hasCollectionInput: Boolean
)

final case class OperationClassification(
    operationName: String,
    kind: OperationKind,
    method: HttpMethod,
    matchedRule: String,        // "M1".."M10"
    targetEntity: Option[String],
    signals: AnalysisSignals
)

The classification algorithm:

  1. Mutation detection. Compare state' references in ensures clause with state references. Any relation where the primed version differs from the unprimed is mutated.

  2. Create detection. If the ensures clause contains x not in pre(R) followed by R'[x] = ..., this is a create operation on the entity stored in R.

  3. Delete detection. If the ensures clause contains x not in R' where x in R is in the requires clause, this is a delete.

  4. Transition detection. If the ensures clause modifies exactly one field (typically status) and the requires clause guards on the current value of that field, this is a state machine transition.

  5. Read detection. If no relation is mutated (all R' = R), this is a read. Single-entity reads have an ID input; collection reads do not.

  6. Update detection. If a relation's value is modified but its key set doesn't change, this is an update. Full vs partial is determined by field coverage (see Section 2.1).

8.4 Phase 3: Rule application

The shipped classifier (Classify.scala) collapses the rule-table form below into a single deterministic top-down match (first match wins, no priorities), the shape this design doc imagines is:

final case class ConventionRule(
    name: String,
    priority: Int,                                 // lower = higher priority
    appliesTo: OperationClassification => Boolean,
    apply: (OperationClassification, EntityInfo) => ConventionDecision
)

Rules are organized into groups:

val HttpMethodRules: List[ConventionRule] = List(
  ConventionRule("M1_create",          priority = 10, appliesTo = isCreate,         apply = (_, _) => POST),
  ConventionRule("M2_read",            priority = 10, appliesTo = isRead,           apply = (_, _) => GET),
  ConventionRule("M3_update_full",     priority = 10, appliesTo = isFullUpdate,     apply = (_, _) => PUT),
  ConventionRule("M4_update_partial",  priority = 10, appliesTo = isPartialUpdate,  apply = (_, _) => PATCH),
  ConventionRule("M5_delete",          priority = 10, appliesTo = isDelete,         apply = (_, _) => DELETE),
  ConventionRule("M6_child_create",    priority =  5, appliesTo = isChildCreate,    apply = (_, _) => POST),
  ConventionRule("M7_search",          priority = 20, appliesTo = isComplexSearch,  apply = decideSearchMethod),
  ConventionRule("M8_action",          priority = 30, appliesTo = isAction,         apply = (_, _) => POST),
  ConventionRule("M9_batch",           priority = 15, appliesTo = isBatch,          apply = (_, _) => POST),
  ConventionRule("M10_transition",     priority =  5, appliesTo = isTransition,     apply = (_, _) => POST)
)

Classify.scala realises this as a top-down if/else over AnalysisSignals, emitting the matched rule's name ("M1""M10") into OperationClassification.matchedRule. M6 (CreateChild) is reserved in the enum but no current dispatch arm produces it.

Priority resolution. When multiple rules match (e.g., an operation both creates an entity AND is a child creation), the rule with the lower priority number wins. If two rules have equal priority and both match, it is a convention engine bug (the rules are designed to be mutually exclusive at each priority level).

Conflict detection. After all rules are applied, the engine checks for conflicts:

  • Two operations mapping to the same (method, path) pair -> compilation error
  • A GET endpoint with a mutation -> warning
  • A DELETE endpoint that also creates -> warning

8.5 Phase 4: Override merge

Overrides are merged in the resolution order specified in Section 3.3 (Path.getConvention is the live entry point):

def resolve(
    property: String,
    op: OperationClassification,
    overrides: Map[String, Expr],
    profile: DeploymentProfile
): Option[String] =
  // 1. Operation-level override
  overrides.get(s"${op.operationName}.$property").flatMap(exprToString)
    // 2. Entity-level override
    .orElse(op.targetEntity.flatMap(t => overrides.get(s"$t.$property")).flatMap(exprToString))
    // 3. Global override
    .orElse(overrides.get(s"global.$property").flatMap(exprToString))
    // 4. Profile default
    .orElse(profile.defaults.get(property))
    // 5. Engine default
    .orElse(engineDefault(property, op))

8.6 Output data structure

The convention engine produces a structured output that downstream code emitters consume. The shipped types (Types.scala) are leaner than the design sketch, EndpointSpec is per-operation, DatabaseSchema is just tables: List[TableSpec], and OpenAPI / triggers / partial indexes are emitted elsewhere or tracked as future work:

final case class EndpointSpec(
    operationName: String,
    method: HttpMethod,            // GET, POST, PUT, PATCH, DELETE
    path: String,                  // /orders/{order_id}/line-items
    pathParams: List[ParamSpec],
    queryParams: List[ParamSpec],
    bodyParams: List[ParamSpec],
    successStatus: Int             // 200, 201, 204, 302
)

final case class ColumnSpec(
    name: String,
    sqlType: String,
    nullable: Boolean,
    defaultValue: Option[String]
)

final case class ForeignKeySpec(
    column: String,
    refTable: String,
    refColumn: String,
    onDelete: String
)

final case class IndexSpec(name: String, columns: List[String], unique: Boolean)

final case class TableSpec(
    name: String,
    entityName: String,
    columns: List[ColumnSpec],
    primaryKey: String,
    foreignKeys: List[ForeignKeySpec],
    checks: List[String],          // CHECK constraint SQL expressions
    indexes: List[IndexSpec]
)

final case class DatabaseSchema(tables: List[TableSpec])

OpenAPI 3.1 emission is handled separately by modules/codegen/.../openapi/; trigger and partial-index derivation are tracked as future work in #57.

8.7 Extensibility: Custom rules and plugins

The convention engine supports two extension points:

1. Custom rules: Users can define additional rules that participate in the rule application phase:

conventions {
  rules {
    // Any operation whose name starts with "Admin" requires admin auth
    match: op.name starts_with "Admin"
    set: op.http_auth = "bearer"
    set: op.http_path_prefix = "/admin"
  }
}

Custom rules are evaluated after built-in rules but before overrides. They cannot override built-in rules (use explicit overrides for that).

2. Profile plugins: Each deployment profile is implemented as a plugin that can define:

  • Additional output artifacts (e.g., Dockerfile, docker-compose.yml, CI config)
  • Profile-specific schema transformations (e.g., using Prisma's schema language instead of raw SQL)
  • Profile-specific validation implementations

Plugin interface (shipped shape: DeploymentProfile + Annotate.buildProfiledService together fill this role; today only python-fastapi-postgres is registered):

trait ProfilePlugin:
  /** Convert generic schema to profile-specific format. */
  def transformSchema(schema: DatabaseSchema): ProfileSchema

  /** Convert generic endpoint to profile-specific route definition. */
  def transformEndpoint(endpoint: EndpointSpec): ProfileEndpoint

  /** Generate additional files (Dockerfile, config, etc.) keyed by output path. */
  def additionalArtifacts(output: ConventionOutput): Map[String, String]

8.8 Testing and debugging

The convention engine is designed to be testable:

Unit testing. Each rule is a pure function that can be tested in isolation (the project uses munit + munit-cats-effect; see modules/convention/src/test/scala/):

test("create operation maps to POST"):
  val op     = makeOp(kind = OperationKind.Create, target = "Order")
  val result = Classify.classifyOperation(op, ir)
  assertEquals(result.method, HttpMethod.POST)

test("read operation maps to GET"):
    op = make_op(kind="read_one", target="Order")
    result = HTTP_METHOD_RULES.apply(op)
  val op2     = makeOp(kind = OperationKind.Read, target = "Order")
  val result2 = Classify.classifyOperation(op2, ir)
  assertEquals(result2.method, HttpMethod.GET)

Decision tracing. The engine can produce a trace of every decision it made, which is invaluable for debugging:

{
  "operation": "AddLineItem",
  "decisions": [
    {
      "aspect": "http_method",
      "rule": "M6_child_create",
      "value": "POST",
      "reason": "Operation creates LineItem (child of Order via line_items relation)"
    },
    {
      "aspect": "url_path",
      "rule": "nested_resource",
      "value": "/orders/{order_id}/line-items",
      "reason": "LineItem is child of Order; order_id is path param (key of orders relation)"
    },
    {
      "aspect": "success_status",
      "rule": "create_201",
      "value": 201,
      "reason": "POST that creates a new entity returns 201 Created"
    },
    {
      "aspect": "error_404",
      "rule": "existence_check",
      "source": "requires: order_id in orders",
      "value": 404,
      "reason": "Entity existence check on orders relation"
    }
  ]
}

This trace is emitted to stderr or a log file when the compiler runs with --verbose or --trace-conventions.

Snapshot testing. The full convention output for each worked example (URL Shortener, E-commerce, Social Feed) is captured as a snapshot test. Any change to the convention rules that alters the output triggers a test failure, forcing explicit review of the change.

8.9 Performance considerations

The convention engine processes each operation independently (no cross-operation dependencies except for path conflict detection). This means:

  • Time complexity. O(E + O * R) where E = number of entities, O = number of operations, R = number of rules. For a typical service with 10 entities and 30 operations, this is sub-millisecond.
  • Memory. The engine holds the full spec IR and produces the full output in memory. For any reasonable spec (thousands of entities), this is trivially small.
  • No I/O. The engine does no file reading, network access, or database queries. It is a pure computation.

Appendix A: Complete decision tree (pseudocode)

function classify_operation(op, spec):
    mutated = {r for r in spec.state if r' != r in op.ensures}
    created = {r for r in mutated if exists x: "x not in pre(r)" in op.ensures}
    deleted = {r for r in mutated if exists x: "x not in r'" in op.ensures}
    transition_fields = detect_status_field_changes(op, spec)

    if mutated is empty:
        if has_collection_output(op):
            return READ_MANY
        else:
            return READ_ONE

    if created is not empty:
        parent = find_parent_entity(created, spec)
        if parent is not None:
            return CHILD_CREATE(parent)
        else:
            return CREATE

    if deleted is not empty:
        return DELETE

    if transition_fields is not empty:
        return TRANSITION(transition_fields)

    modified_fields = fields_assigned_in_ensures(op)
    entity_fields = all_fields_of(target_entity(op, spec))
    if modified_fields == entity_fields:
        return UPDATE_FULL
    else:
        return UPDATE_PARTIAL


function determine_http_method(classification):
    match classification:
        CREATE | CHILD_CREATE -> POST
        READ_ONE | READ_MANY  -> GET
        UPDATE_FULL           -> PUT
        UPDATE_PARTIAL        -> PATCH
        DELETE                -> DELETE
        TRANSITION            -> POST
        ACTION                -> POST


function determine_url_path(op, classification, spec):
    entity = target_entity(op, spec)
    plural = pluralize(entity.name)

    match classification:
        CREATE:
            return f"/{plural}"
        READ_ONE | UPDATE_FULL | UPDATE_PARTIAL | DELETE:
            id_param = find_id_param(op, entity, spec)
            return f"/{plural}/{{{id_param.name}}}"
        READ_MANY:
            return f"/{plural}"
        CHILD_CREATE(parent):
            parent_plural = pluralize(parent.name)
            parent_id = find_id_param(op, parent, spec)
            child_plural = pluralize(entity.name)
            return f"/{parent_plural}/{{{parent_id.name}}}/{child_plural}"
        TRANSITION(field):
            action = extract_action_verb(op.name, entity.name)
            id_param = find_id_param(op, entity, spec)
            return f"/{plural}/{{{id_param.name}}}/{action}"


function determine_status_codes(op, classification, spec):
    success = match classification:
        CREATE | CHILD_CREATE -> 201
        READ_ONE | READ_MANY | UPDATE_FULL | UPDATE_PARTIAL | TRANSITION | ACTION -> 200
        DELETE -> 204

    errors = []
    for clause in op.requires:
        if is_existence_check(clause):
            errors.append(404)
        elif is_uniqueness_check(clause):
            errors.append(409)
        elif is_state_guard(clause):
            errors.append(409)
        elif is_value_validation(clause):
            errors.append(422)

    return success, errors

Appendix B: Naming convention reference

ConceptConventionExample
DB table namePlural, snake_caseline_items
DB column nameSingular, snake_caseorder_id
DB FK column{referenced_table_singular}_idproduct_id
DB junction table{table1}_{table2}_{relation}user_follows
DB index nameidx_{table}_{column(s)}idx_orders_status
DB trigger nametrg_{table}_{purpose}trg_orders_updated_at
API path segmentPlural, kebab-case/line-items
API path paramSingular, snake_case in braces{order_id}
API query paramsnake_casesort_by
JSON field (default)snake_casecustomer_email
OpenAPI operationIdcamelCase, verb + nounaddLineItem
Error codeSCREAMING_SNAKE_CASEORDER_NOT_FOUND
Pydantic modelPascalCase + suffixShortenRequest
Go structPascalCaseLineItem
Java classPascalCaseShortCodeController

Appendix C: Multiplicity quick reference

Alloy SyntaxMeaningDB SchemaExample
A -> one BEvery A has exactly one BNOT NULL FK in A's tablepost_author: Post -> one User
A -> lone BEvery A has at most one BNullable FK in A's tablestore: ShortCode -> lone LongURL
A -> some BEvery A has one or more BsJunction table + min-1 triggertags: Article -> some Tag
A -> set BEvery A has zero or more BsJunction tablelikes: User -> set Post
A -> B (bare)Same as A -> one BNOT NULL FKDefault multiplicity

Appendix D: Security defaults in generated code

The convention engine applies secure defaults to every generated service. These can be overridden via the conventions block but are designed to be safe out of the box.

D.1 CORS policy

The default CORS configuration is restrictive:

conventions {
  global.cors.allow_origins = []          // no origins allowed by default
  global.cors.allow_methods = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"]
  global.cors.allow_headers = ["Authorization", "Content-Type"]
  global.cors.max_age = 86400             // 24 hours preflight cache
}

Developers must explicitly list allowed origins. The generated code never uses allow_origins = ["*"] unless the user overrides to global.cors.allow_origins = ["*"].

D.2 Rate limiting

Default rate limiting is applied globally and can be customized per-operation:

conventions {
  global.rate_limit = "100/minute"            // default for all endpoints
  CreateUser.rate_limit = "10/minute"         // tighter limit for account creation
  Login.rate_limit = "5/minute"               // brute-force protection
}

The convention engine generates rate-limiting middleware using an in-memory token bucket (single instance) or Redis-backed token bucket (when a Redis convention profile is active). Rate-limited requests receive HTTP 429 with a Retry-After header.

D.3 Input sanitization and request limits

ConventionDefaultOverride
Max request body size1 MBglobal.max_request_body = "10MB"
Max JSON nesting depth20 levelsglobal.max_json_depth = 50
String field max length10,000 chars (unless entity specifies otherwise)Per-field where constraint
Regex complexity checkEnabled, rejects ReDoS-vulnerable patterns in entity invariantsglobal.regex_safety_check = false
SQL injectionPrevented by default (ORM + parameterized queries)Not overridable

Appendix E: API versioning conventions

The convention engine supports three API versioning strategies, selected via convention override. The default is URL-prefix versioning.

E.1 URL prefix versioning (default)

conventions {
  global.api_version = "v1"
  global.versioning_strategy = "url_prefix"    // default
}

All generated paths are prefixed: POST /v1/shorten, GET /v1/{code}, etc. When the spec evolves to a breaking change, the developer bumps global.api_version = "v2" and the compiler can generate both versions side by side (the old spec file is preserved as v1.spec, the new one as v2.spec).

E.2 Header-based versioning

conventions {
  global.api_version = "1"
  global.versioning_strategy = "header"
  global.version_header = "X-API-Version"      // default header name
}

The generated router inspects the X-API-Version header and routes to the appropriate handler. Missing header defaults to the latest version. The OpenAPI spec includes the header as a parameter on every operation.

E.3 Content negotiation (accept header)

conventions {
  global.api_version = "1"
  global.versioning_strategy = "content_type"
}

Clients send Accept: application/vnd.service-name.v1+json. The generated router parses the vendor media type and routes accordingly. This follows GitHub API conventions.

E.4 Convention engine mapping

StrategyPathHeaderMedia Type
url_prefix (default)/v1/shortenapplication/json
header/shortenX-API-Version: 1application/json
content_type/shortenapplication/vnd.myservice.v1+json

Appendix F: Caching conventions

The convention engine generates HTTP caching headers and ETag support for read operations, enabling clients and CDNs to cache responses efficiently.

F.1 Etag generation

For every GET response that returns an entity with an updated_at or version field, the convention engine generates an ETag header:

ETag: "{entity_type}:{id}:{version}"       // if version field exists
ETag: "{entity_type}:{id}:{updated_at_epoch}"  // fallback to timestamp

The generated router supports conditional requests:

  • If-None-Match on GET: returns HTTP 304 Not Modified if the ETag matches
  • If-Match on PUT/PATCH/DELETE: returns HTTP 412 Precondition Failed if stale

F.2 Cache-control headers

Default cache-control conventions based on operation type:

Operation TypeDefault Cache-ControlRationale
GET single entityprivate, max-age=0, must-revalidateSafe with ETag; client revalidates
GET collection/listprivate, max-age=60Short TTL for lists
POST/PUT/PATCH/DELETEno-storeMutations must not be cached

Override via conventions:

conventions {
  Resolve.cache_control = "public, max-age=3600"    // CDN-cacheable for 1 hour
  GetFeed.cache_control = "private, max-age=30"     // short-lived personalized content
}

F.3 Application-level caching

When a Redis convention profile is active, the convention engine generates a read-through cache for GET operations. Cache invalidation is triggered automatically by any operation that mutates the same state relation:

conventions {
  global.cache_backend = "redis"
  Resolve.cache_ttl = 3600                 // cache resolved URLs for 1 hour
}

The generated code invalidates the cache entry for a short code whenever Delete or any operation mutating store is invoked for that key.

On this page

Table of contents1. Design philosophyCore principles2. The complete convention ruleset2.1 HTTP method mappingRule tablePUT vs PATCH disambiguationOperations that don't fit2.2 URL path mappingResource name derivationPluralization rulesNested resource detectionPath parameter extractionQuery parameter mapping for filters2.3 HTTP status code mappingSuccess status codesError status codes from requires clausesPriority logic for compound requires clausesError status codes from invariant violations2.4 Request/response body mappingInput-to-request mappingOutput-to-response mappingNaming conventionsEnvelope format2.5 Database schema mappingEntity-to-table mappingType mappingRelation-to-schema mappingJunction table structureInvariant-to-constraint mappingAutomatic columnsIndex generation2.6 Validation mappingLayer 1: HTTP / request validation (before any application logic)Layer 2: Application / business logic validation (after parsing, before db)Layer 3: Database constraints (last line of defense)Validation error aggregation2.7 Error response mappingStructured error codesHuman-readable error messages3. Convention override system3.1 Override syntax3.2 Override categories3.3 Override resolution order3.4 Override conflicts3.5 What cannot be overridden4. Worked examples4.1 URL shortenerSpecConvention engine output: HTTP endpointsDecision trace for endpoint 1 (shorten)Decision trace for endpoint 2 (resolve)Convention engine output: SQL DDLConvention engine output: OpenAPI snippetValidation logic description4.2 E-commerce order serviceSpecConvention engine output: HTTP endpointsDecision trace for endpoint 6 (addlineitem)Decision trace for endpoints 8-11 (state machine transitions)Convention engine output: SQL DDLSchema design decisionsState machine visualization4.3 Social media feedSpecConvention engine output: HTTP endpointsDecision trace for endpoint 5 (follow)Decision trace for endpoint 6 (unfollow)Decision trace for endpoint 4 (getfeed)Convention engine output: SQL DDL (junction tables)Junction table design decisionsPagination convention5. Edge cases and ambiguities5.1 Operations that both read AND write5.2 Operations with multiple entity inputs5.3 Operations that create child entities5.4 Idempotency5.5 Optional vs required inputs5.6 Collection returns: Pagination, filtering, sorting5.7 File uploads and binary data5.8 Async operations and long-running tasks5.9 Webhooks6. Convention profiles (deployment targets)6.1 python-fastapi-postgres6.2 go-chi-postgres6.3 typescript-express-prisma6.4 java-spring-jpa7. Comparison with existing convention systems7.1 Ruby on Rails (convention over configuration)What Rails doesWhat we learnWhat we do differently7.2 JHipster JDL (entity-to-Spring-boot)What JHipster doesWhat we learnWhat we do differently7.3 Django REST framework (model -> serializer -> viewset -> router)What DRF doesWhat we learnWhat we do differently7.4 Smithy traits (protocol-agnostic behavior)What Smithy doesWhat we learnWhat we do differently7.5 OpenAPI code generatorsWhat they doWhat we learnWhat we do differently7.6 Summary comparison table8. Implementation architecture8.1 Internal structure8.2 Phase 1: Entity analysis8.3 Phase 2: Operation analysis8.4 Phase 3: Rule application8.5 Phase 4: Override merge8.6 Output data structure8.7 Extensibility: Custom rules and plugins8.8 Testing and debugging8.9 Performance considerationsAppendix A: Complete decision tree (pseudocode)Appendix B: Naming convention referenceAppendix C: Multiplicity quick referenceAppendix D: Security defaults in generated codeD.1 CORS policyD.2 Rate limitingD.3 Input sanitization and request limitsAppendix E: API versioning conventionsE.1 URL prefix versioning (default)E.2 Header-based versioningE.3 Content negotiation (accept header)E.4 Convention engine mappingAppendix F: Caching conventionsF.1 Etag generationF.2 Cache-control headersF.3 Application-level caching