Migration

Lift → AppTheory Migration Guide

Goal: provide a predictable migration path from pay-theory/lift to AppTheory.

Posture: “easy migration”, not drop-in identical. Pay Theory’s requirement is that 100% of Lift’s current functionality remains available for Go users (portable subset + documented Go-only extensions).

Start Here

  • Consolidated v1.0 security migration notes: docs/migration/v1-security.md
  • Representative migration notes: docs/migration/g4-representative-migration.md
  • Canonical interface map: docs/api-reference.md
  • Canonical runtime patterns: docs/core-patterns.md
  • Canonical verification flow: docs/testing-guide.md

Quick Start (Go Service)

Use this sequence when you want the smallest safe migration path for a Lift-backed Go service.

  1. Replace limited imports (safe, diff-based):
    • Dry-run: ./scripts/migrate-from-lift-go.sh -root path/to/service
    • Apply: ./scripts/migrate-from-lift-go.sh -root path/to/service -apply
  2. Replace Lift runtime wiring with apptheory.New() + route registration.
  3. Configure AWS entrypoint(s):
    • If you used Lift as a single Lambda router across trigger types, prefer app.HandleLambda.
    • API Gateway v2 (HTTP API): app.ServeAPIGatewayV2
    • Lambda Function URL: app.ServeLambdaFunctionURL
  4. Run your service tests + make rubric in AppTheory for contract parity expectations.

Step-By-Step Migration (Go)

Use these sections when you need to reason about parity one surface at a time instead of applying the quick-start sequence mechanically.

1) Update imports and dependencies

Core import root:

  • Lift: github.com/pay-theory/lift/...
  • AppTheory: github.com/theory-cloud/apptheory/runtime

Rate limiting:

  • Lift historically: github.com/pay-theory/limited
  • AppTheory: github.com/theory-cloud/apptheory/pkg/limited (+ pkg/limited/middleware)

Data (DynamoDB):

  • Lift historically: DynamORM
  • AppTheory: TableTheory (github.com/theory-cloud/tabletheory) is the companion data framework and replaces DynamORM for AppTheory work.

2) Replace app/router/handler surfaces

App container:

  • Lift: lift.New(...)
  • AppTheory: apptheory.New(...)

Tier selection:

  • Default is TierP2 (prod features: observability hooks, policy hook, rate limiting semantics).
  • You can explicitly set: apptheory.WithTier(apptheory.TierP0 | TierP1 | TierP2).

Routes:

  • Lift-style routing maps directly:
    • app.Get("/path", handler)
    • app.Post("/path", handler)
    • app.Handle(method, "/path", handler)

Handler signature (Go):

  • Lift: varies across packages and middleware; may be Go-specific.
  • AppTheory: func(*apptheory.Context) (*apptheory.Response, error)

Response helpers:

  • apptheory.Text(status, "text")
  • apptheory.JSON(status, value) (returns (*Response, error))
  • apptheory.Binary(status, bytes, contentType)

3) Middleware, ordering, and limits

AppTheory P1/P2 has a contract-defined ordering (fixture-backed):

  • request-id → recovery → logging → CORS → auth → handler

Size limits:

  • Configure with apptheory.WithLimits(apptheory.Limits{ MaxRequestBytes: ..., MaxResponseBytes: ... })

Custom middleware (Lift parity):

  • Register global middleware with app.Use(mw) (applied in registration order: m1 -> m2 -> handler).
  • Share request-scoped state across middleware + handlers with ctx.Set(key, value) / ctx.Get(key).
  • Contract-defined built-ins still run in their fixed order; user middleware wraps the final handler stage so it doesn’t reorder request-id/auth/CORS invariants.

4) Auth and protected routes

Configure the auth hook:

  • apptheory.WithAuthHook(func(ctx *apptheory.Context) (string, error) { ... })

Require auth per-route:

  • app.Get("/path", handler, apptheory.RequireAuth())

Semantics:

  • If auth is required and identity cannot be established, AppTheory returns app.unauthorized (401).

5) Request ID and tenant behavior

Request ID:

  • Header: x-request-id
  • If provided, it is propagated; otherwise generated.
  • Available to handlers: ctx.RequestID

Tenant:

  • x-tenant-id header, then tenant query parameter.
  • Available to handlers: ctx.TenantID

6) Rate limiting (Lift limited replacement)

AppTheory ports the limited feature set in-repo:

  • Package: github.com/theory-cloud/apptheory/pkg/limited
  • net/http middleware: github.com/theory-cloud/apptheory/pkg/limited/middleware

Backing store:

  • DynamoDB via TableTheory (not DynamORM).

Reference example:

  • examples/migration/rate-limited-http/README.md

6b) EventBus (Lift pkg/services) (Autheory)

AppTheory ports the Lift EventBus surface needed by Autheory:

  • Lift: github.com/pay-theory/lift/pkg/services
  • AppTheory: github.com/theory-cloud/apptheory/pkg/services

Key mapping:

  • services.NewEvent(...)services.NewEvent(...)
  • services.NewMemoryEventBus()services.NewMemoryEventBus() (tests/local)
  • services.NewDynamoDBEventBus(...)services.NewDynamoDBEventBus(...) (production; TableTheory-backed)

Notes:

  • DynamoDB backing uses TableTheory (no raw AWS SDK DynamoDB calls).
  • Table name can be set via EventBusConfig.TableName or env APPTHEORY_EVENTBUS_TABLE_NAME (migration-friendly fallbacks exist for Autheory deployments).
  • Cursor pagination uses EventQuery.LastEvaluatedKey["cursor"] and returns EventQuery.NextKey["cursor"].
  • DynamoDBEventBus.Query(...) requires TenantID; MemoryEventBus.Query(...) also supports event-type-only queries (useful for adapter tests).

7) Observability (logs/metrics/traces)

AppTheory’s portable observability surface is hook-based:

  • apptheory.WithObservability(apptheory.ObservabilityHooks{ Log: ..., Metric: ..., Span: ... })

Portable schema is fixture-backed (see parity matrix and contract tests).

Legacy SNS error notification env var support:

  • ERROR_NOTIFICATION_SNS_TOPIC_ARN is accepted alongside AppTheory’s standard SNS env vars.

7a) Error handling (LiftError parity)

Lift’s LiftError maps to AppTheory’s portable error type:

  • Go: apptheory.AppTheoryError (constructor + fluent helpers)
  • TS: AppTheoryError
  • Py: AppTheoryError

Go example:

return nil, apptheory.NewAppTheoryError("app.conflict", "conflict").
  WithStatusCode(409).
  WithDetails(map[string]any{"field": "email", "retryable": false}).
  WithTraceID("trace_123")

TypeScript example:

throw new AppTheoryError("app.conflict", "conflict", {
  statusCode: 409,
  details: { field: "email", retryable: false },
  traceId: "trace_123",
});

Python example:

raise AppTheoryError(
    code="app.conflict",
    message="conflict",
    status_code=409,
    details={"field": "email", "retryable": False},
    trace_id="trace_123",
)

Notes:

  • AppError remains supported for simple code/message responses.
  • request_id is automatically injected from the runtime when available; you can override it on the error if needed.
  • stack_trace is opt-in and never emitted by default.
  • HTTP services that must preserve Lift’s flat { code, message, details? } response body can opt in without changing AppSync or WebSocket error behavior:
    • Go: apptheory.New(apptheory.WithLegacyHTTPErrorShape())
    • TypeScript: createApp({ httpErrorFormat: HTTP_ERROR_FORMAT_FLAT_LEGACY })
    • Python: create_app(http_error_format=HTTP_ERROR_FORMAT_FLAT_LEGACY)

7b) Global logger singleton (LiftLogger parity)

AppTheory provides a global, no-op-by-default logger that mirrors Lift’s singleton usage pattern.

Go (uses observability.StructuredLogger):

zapLogger, _ := zap.NewZapLogger(observability.LoggerConfig{})
logger.SetLogger(zapLogger)
logger.Logger().Info("request", map[string]any{"request_id": ctx.RequestID})

TypeScript:

setLogger(myLogger)
getLogger().info("request", { request_id: ctx.requestId })

Python:

set_logger(my_logger)
get_logger().info("request", {"request_id": ctx.request_id})

Sanitization helpers are exposed alongside the logger (Go: logger.SanitizeJSON, logger.SanitizeLogString, logger.PaymentXMLPatterns; TS/Py: re-exports from the logger module).

Lift-compatible masking helpers are available in sanitization:

  • Go: sanitization.MaskFirstLast, sanitization.MaskFirstLast4
  • TS: maskFirstLast, maskFirstLast4
  • Py: mask_first_last, mask_first_last4

These helpers return "(empty)" for empty input and "***masked***" when the value is too short.

For service logs, prefer AppTheory’s fixture-backed logging profiles over service-local encoders. Select a built-in profile such as paytheory-alert-v1, provide required static enrichment from deployment configuration, and keep Slack/alert routing in the operations layer that consumes the JSON logs.

8) AWS entrypoints (HTTP)

Contract v0 covers AWS HTTP events:

  • Lambda Function URL
  • API Gateway v2 (HTTP API)

Go entrypoints:

  • app.ServeLambdaFunctionURL(ctx, events.LambdaFunctionURLRequest)
  • app.ServeAPIGatewayV2(ctx, events.APIGatewayV2HTTPRequest)

For local tests:

  • Go testkit: apptheory/testkit (build synthetic events; invoke adapters).

9) AWS entrypoints (REST API v1 + SSE)

REST API v1 (Lambda proxy integration) is supported for Lift parity and SSE endpoints.

Go entrypoint:

  • app.ServeAPIGatewayProxy(ctx, events.APIGatewayProxyRequest)

SSE responses:

  • Use apptheory.SSEResponse(status, ...events) (or apptheory.MustSSEResponse) to build a properly framed SSE response.
  • For API Gateway REST API v1 SSE, enable method-level streaming in infra (see CDK section below).

Event-by-event SSE streaming (no full-body buffering):

  • Go: apptheory.SSEStreamResponse(ctx, status, <-chan apptheory.SSEEvent)
  • TS: sseEventStream(AsyncIterable<SSEEvent>) yields framed chunks
  • Py: sse_event_stream(Iterable[SSEEvent]) yields framed chunks

10) AWS entrypoints (WebSockets)

Register WebSocket route handlers:

  • app.WebSocket("$connect", handler)
  • app.WebSocket("$disconnect", handler)
  • app.WebSocket("$default", handler)

In handlers, access the WebSocket context:

  • ws := ctx.AsWebSocket()
  • ws.SendMessage(...) / ws.SendJSONMessage(...)

Go entrypoint:

  • app.ServeWebSocket(ctx, events.APIGatewayWebsocketProxyRequest)

For local tests:

  • Go testkit builder: testkit.WebSocketEvent(...)
  • Go fake management client: testkit.NewFakeStreamerClient(endpoint) + apptheory.WithWebSocketClientFactory(...)

11) AWS entrypoints (SQS / EventBridge / DynamoDB Streams)

Lift’s “single Lambda router” pattern across non-HTTP triggers maps to explicit registration in AppTheory.

SQS:

  • Register: app.SQS(queueName, handler)
  • Entrypoint: app.ServeSQS(ctx, events.SQSEvent)

EventBridge:

  • Register by rule: app.EventBridge(apptheory.EventBridgeRule(ruleName), handler)
  • Or by pattern: app.EventBridge(apptheory.EventBridgePattern(source, detailType), handler)
  • Entrypoint: app.ServeEventBridge(ctx, events.EventBridgeEvent)
  • Event shape: AppTheory accepts both detail-type (EventBridge native) and detailType (camelCase) for parity.

DynamoDB Streams:

  • Register: app.DynamoDB(tableName, handler)
  • Entrypoint: app.ServeDynamoDBStream(ctx, events.DynamoDBEvent)

For local tests:

  • Go testkit builders: testkit.SQSEvent(...), testkit.EventBridgeEvent(...), testkit.DynamoDBStreamEvent(...)
  • AppSync builders: testkit.AppSyncEvent(...), buildAppSyncEvent(...), build_appsync_event(...)

11a) AppSync resolvers

AppTheory supports standard AWS AppSync direct Lambda resolver events in Go, TypeScript, and Python.

Compatibility posture:

  • Legacy reference: github.com/pay-theory/lift
  • Explicit entrypoints:
    • Go: app.ServeAppSync(ctx, event)
    • TypeScript: app.serveAppSync(event, ctx)
    • Python: app.serve_appsync(event, ctx)
  • Universal dispatch:
    • HandleLambda, handleLambda, and handle_lambda detect standard AppSync events by info.fieldName, info.parentTypeName, and arguments
  • Lift-compatible request adaptation:
    • Mutation -> POST /fieldName
    • Query -> GET /fieldName
    • Subscription -> GET /fieldName
    • top-level arguments become the JSON request body
    • request.headers are forwarded
  • Lift-compatible response behavior:
    • successful resolvers return the resolver payload directly, not an HTTP proxy envelope
    • handler failures return AppSync error objects with pay_theory_error, error_message, error_type, error_data, and error_info

Typed resolver metadata:

  • Go: ctx.AsAppSync()
  • TypeScript: ctx.asAppSync()
  • Python: ctx.as_appsync()

Out of scope differences that still remain:

  • AppSync Lambda authorizers are not part of this runtime feature set
  • binary and streaming response bodies fail closed with deterministic AppSync system errors
  • AppTheory does not generate GraphQL schemas or AppSync request/response mapping templates

Reference recipe:

  • docs/migration/appsync-lambda-resolvers.md

12) One-entrypoint router (Lift-style)

If your Lift app handled multiple AWS trigger types in a single Lambda, AppTheory provides the same posture via a single entrypoint:

  • Go: app.HandleLambda(ctx, json.RawMessage)

This entrypoint routes:

  • Lambda URL, API Gateway v2, API Gateway REST v1
  • WebSockets (APIGW v2 WebSocket API)
  • SQS, EventBridge, DynamoDB Streams
  • AppSync direct Lambda resolver events

13) CDK migration notes (Lift constructs → AppTheory constructs)

AppTheory ships TS-first jsii CDK constructs, consumable from Go/TS/Python.

Common Lift construct mappings used by Lesser:

  • Lift REST API v1: LiftRestAPIAppTheoryRestApi (supports per-method streaming toggles for SSE endpoints)
  • Lift schedules: EventBridge rule + Lambda target → AppTheoryEventBridgeHandler
  • Lift stream mappings: DynamoDB stream event source mapping → AppTheoryDynamoDBStreamMapping
  • Lift function defaults wrapper: LiftFunctionAppTheoryFunction
  • Lift EventBus table: EventBusTableAppTheoryEventBusTable (DynamoDB schema for pkg/services EventBus)

Practical Mapping Table (High-Leverage)

This table is a migration-focused subset. Internal planning inventories may exist outside the canonical docs root, but they are intentionally omitted from this user-facing guide.

Lift symbol/pattern AppTheory equivalent Notes
lift.New() apptheory.New() new app/router surface rooted at AppTheory
app.Get("/path", handler) app.Get("/path", handler) handler signature changes to portable *Context
Lift handler funcs apptheory.Handler func(*apptheory.Context) (*apptheory.Response, error)
Lift JSON helpers ctx.JSONValue() + json.Unmarshal portable JSON parsing semantics are contract-defined
Lift AppSync resolver adapter HandleLambda(...) or ServeAppSync(...) standard AppSync direct Lambda event shape; ctx.AsAppSync() exposes resolver metadata
lift.SSEResponse / lift.SSEEvent apptheory.SSEResponse / apptheory.SSEEvent REST API v1 + SSE helpers
Lift app.Use(...) + ctx.Set/Get app.Use(...) + ctx.Set/Get global middleware pipeline + context value bag
Lift pkg/naming pkg/naming deterministic naming helpers (stage/name builders)
app.WebSocket("$connect", handler) app.WebSocket("$connect", handler) ctx.AsWebSocket() returns *WebSocketContext
wsCtx.SendJSONMessage(...) ws.SendJSONMessage(...) uses API Gateway Management API via pkg/streamer
app.SQS(queue, handler) app.SQS(queue, handler) SQS routing by queue name
app.EventBridge(...) app.EventBridge(...) match by rule name or by source/detail-type
app.DynamoDB(table, handler) app.DynamoDB(table, handler) DynamoDB Streams routing by table name
github.com/pay-theory/limited apptheory/pkg/limited replicated feature set; TableTheory-backed
Lift pkg/services (EventBus) apptheory/pkg/services (EventBus) Lift-compatible API; DynamoDB implementation uses TableTheory
Lift EventBusTable (CDK) AppTheoryEventBusTable (CDK) provisions EventBus DynamoDB table schema + GSIs
DynamORM usage TableTheory companion data framework for AppTheory

Known Differences (Intentional)

  • AppTheory is multi-language contract-first; behavior is fixture-backed and versioned.
  • Some Lift APIs may change for portability and determinism; the guide calls out migration steps rather than promising drop-in compatibility.
  • TableTheory replaces DynamORM for DynamoDB access in AppTheory work.
  • Observability is expressed via portable hooks (provider wiring may remain Go-only initially).

Automation Helpers

Available now:

  • ./scripts/migrate-from-lift-go.sh:
    • Scope: rewrites github.com/pay-theory/limitedgithub.com/theory-cloud/apptheory/pkg/limited (and subpackages)
    • Safe by default: dry-run prints unified diffs
  • cmd/lift-migrate:
    • Programmatic import rewriting tool (used by the script above)

Planned:

  • Additional import rewrites for common Lift package paths (opt-in, diff-based).
  • Optional helpers for DynamORM → TableTheory migration where safe.

Validation Checklist

  • Review docs/migration/v1-security.md before cutover for fail-closed changes that affect auth hooks, CORS, AppSync error masking, SSR/CDK defaults, MCP transport, and logging expectations.
  • Service builds and passes its unit/integration tests.
  • End-to-end HTTP behavior matches expected client contracts (error codes/envelopes, CORS/auth behavior).
  • Rate limiting behavior matches limited semantics where used.
  • If your Go HTTP service relied on raw API keys or Bearer tokens appearing in rate-limit tables or dashboards, update those expectations before cutover. AppTheory’s default RateLimitMiddleware(...) now fingerprints credential-derived identifiers (api_key:hmac-sha256:... / bearer:hmac-sha256:...), which intentionally resets those buckets and changes the observable identifier values.
  • Deploy templates updated (CDK/examples as needed).

Representative Migration (G4)

  • Example: examples/migration/rate-limited-http/README.md
  • Lessons learned: docs/migration/g4-representative-migration.md