Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ Read paths: `listMyProfiles` is a pipeline (`list_my_profiles` → `batch_latest

### Batch-resolving `Campaign.catalog`/`SharedCampaign.catalog` (#332, admin Lambda side)

The `Campaign.catalog`/`SharedCampaign.catalog` field resolvers are pipelines whose first function, `check_source_catalog_fn.js`, early-returns whenever the parent already attached `catalog` to the source item — so any list-returning resolver can kill the per-item GetItem N+1 by batch-attaching catalogs itself. The #332-era list pipelines do this in AppSync (`batch_get_catalogs_fn.js`, `batch_get_shared_campaign_catalogs_fn.js`, shared helpers in `js-resolvers/lib/batch_catalogs.js`), where >100 distinct ids on a page must skip/error because a pipeline function cannot loop. The admin list handlers `admin_get_user_campaigns`/`admin_get_user_shared_campaigns` in `src/handlers/admin_operations.py` do the same inside the Lambda (`_batch_get_campaign_catalogs`: chunked BatchGetItem with 100-key chunks and UnprocessedKeys retry) — Lambdas can loop, so there is no page-size cap. Contracts differ per type and must be preserved when touching either side: Campaign.catalog returns the raw item including soft-deleted catalogs; SharedCampaign.catalog maps soft-deleted or missing catalogs to null. Missing catalog ids always map to `catalog = null` (never omit the key — omission would re-trigger the field resolver's GetItem).
The `Campaign.catalog`/`SharedCampaign.catalog` field resolvers are pipelines whose first function, `check_source_catalog_fn.js`, early-returns whenever the parent already attached `catalog` to the source item — so any list-returning resolver can kill the per-item GetItem N+1 by batch-attaching catalogs itself. The #332-era list pipelines do this in AppSync (`batch_get_catalogs_fn.js`, `batch_get_shared_campaign_catalogs_fn.js`, shared helpers in `js-resolvers/lib/batch_catalogs.js`), where >100 distinct ids on a page must skip/error because a pipeline function cannot loop. The admin list handlers `admin_get_user_campaigns`/`admin_get_user_shared_campaigns` in `src/handlers/admin_user_reads.py` do the same inside the Lambda (`_batch_get_campaign_catalogs`: chunked BatchGetItem with 100-key chunks and UnprocessedKeys retry) — Lambdas can loop, so there is no page-size cap. Contracts differ per type and must be preserved when touching either side: Campaign.catalog returns the raw item including soft-deleted catalogs; SharedCampaign.catalog maps soft-deleted or missing catalogs to null. Missing catalog ids always map to `catalog = null` (never omit the key — omission would re-trigger the field resolver's GetItem).

### AppSync resolver bundling prerequisite (#277/#282/#288)

Expand Down Expand Up @@ -188,7 +188,7 @@ This is a conscious, documented security posture. If schema-level owner authoriz

### Lambda exception-handling decorator (#294, contract changed by #329)

`src/utils/handlers.py` provides the `lambda_handler` decorator (imported in handler modules as `with_error_handling`). Since #329 it does NOT re-raise: both `AppError` and unexpected exceptions are converted to a returned structured payload `{"__isError": True, "errorCode": ..., "message": ...}` (re-raising would let Lambda serialize only `errorType`/`errorMessage` and lose the code). The 16 admin-operations UNIT resolvers (everything on the `admin_operations` Lambda datasource in `tofu/application/modules/appsync/resolvers_mutations.tf`/`resolvers_queries.tf`) run `lambda_passthrough_resolver.js`, whose `request()` forwards `{arguments, identity, info, prev, stash}` to the handler (the dispatcher needs `info.fieldName`; `is_admin` needs `identity.claims`) and whose `response()` detects `ctx.result.__isError` and calls `util.error(message, errorCode, null, { errorCode })` so the code lands in GraphQL extensions; the frontend's `getErrorCode()` reads `extensions.errorCode`, `extensions.code`, and `extensions.errorType` (#329). The 11 direct Lambda UNIT resolvers on the other decorator-wrapped handlers (listCatalogsInUse, listUnitCatalogs, getUnitReport, listUnitCampaignCatalogs, deleteMyAccount, transferProfileOwnership, requestCampaignReport, request/deletePaymentMethodQRCode*, and the API-key `publicGetOrderOffer`/`publicGetOrderReceipt` fields on the public-orders Lambda, where `identity` is null under API_KEY auth and is never read) run `lambda_unit_resolver.js`, which mirrors the default whole-context Invoke payload and applies the same `__isError` detection. The pipeline functions invoking decorated handlers detect `__isError` too: `delete_campaign_orders_lambda_fn.js` util.errors to abort the deleteCampaign pipeline so `delete_campaign` never runs after a failed order-deletion step, `delete_profile_cascade_fn.js`, `confirm_qr_upload_fn.js`, and `batch_qr_urls_fn.js` util.error, the best-effort `delete_payment_method_qr_code_fn.js` swallows it exactly like `ctx.error`, and the best-effort public-orders mailer step `send_public_order_emails_fn.js` swallows both invoke-level `ctx.error` and a structured `__isError`, reporting `confirmationEmailSent: false` instead of failing the order. The generic path logs an error naming the function and returns `INTERNAL_ERROR`; use `@lambda_handler(error_message="...")` for a client-facing message. Only the generic unexpected-exception path is covered — handler-specific typed errors (e.g. retryable `RESOURCE_BUSY` in `_raise_batch_lookup_error`) must stay in the handler/helpers, which is why those helpers' tests still assert `pytest.raises(AppError)`. All AppSync resolver handlers are migrated (#337); handler modules import it under `TYPE_CHECKING` for mypy (relative) with an absolute/relative runtime fallback (see `admin_operations.py`). The Cognito triggers are deliberately NOT decorated: `pre_signup.py` must propagate `FederatedIdentityLinkedException` and original error messages to Cognito, and `post_authentication.py` deliberately swallows exceptions and returns the event so auth never fails on DynamoDB issues. They still log through the project's structured logger — `get_logger(__name__)` from `src/utils/logging.py` (absolute import with a relative fallback) with message + keyword fields, never `logging.getLogger()` at module scope, which mutates the root logger and emits plain-text lines that are not machine-parseable (#565). The single canonical error-code vocabulary is `ErrorCode` in `src/utils/errors.py`; JS resolvers and VTL templates should emit those exact strings (e.g. `INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `UNAUTHORIZED`, `INTERNAL_ERROR`), not HTTP-style spellings like `BadRequest` (#329 aligned the HTTP-style spellings; legacy types such as `ConflictException` (also the payment-method optimistic-lock conflict from #433), `CatalogInUse`, `RateLimitExceeded`, and `ALREADY_SHARED` remain in some share/invite/usage resolvers). `UNAUTHORIZED` means "session invalid, sign in again" (frontend `isAuthError`/`mapErrorCodeToMessage`): reserve it for a missing/invalid caller identity, and use `FORBIDDEN` for a failed ownership/permission condition on an already-authenticated caller (#439, #571). Overload stub lines in `src/utils/handlers.py` carry `pragma: no cover` (type-checking-only); the module must stay at 100% coverage.
`src/utils/handlers.py` provides the `lambda_handler` decorator (imported in handler modules as `with_error_handling`). Since #329 it does NOT re-raise: both `AppError` and unexpected exceptions are converted to a returned structured payload `{"__isError": True, "errorCode": ..., "message": ...}` (re-raising would let Lambda serialize only `errorType`/`errorMessage` and lose the code). The 16 admin-operations UNIT resolvers (everything on the `admin_operations` Lambda datasource in `tofu/application/modules/appsync/resolvers_mutations.tf`/`resolvers_queries.tf`) run `lambda_passthrough_resolver.js`, whose `request()` forwards `{arguments, identity, info, prev, stash}` to the handler (the dispatcher needs `info.fieldName`; `is_admin` needs `identity.claims`) and whose `response()` detects `ctx.result.__isError` and calls `util.error(message, errorCode, null, { errorCode })` so the code lands in GraphQL extensions; the frontend's `getErrorCode()` reads `extensions.errorCode`, `extensions.code`, and `extensions.errorType` (#329). The 11 direct Lambda UNIT resolvers on the other decorator-wrapped handlers (listCatalogsInUse, listUnitCatalogs, getUnitReport, listUnitCampaignCatalogs, deleteMyAccount, transferProfileOwnership, requestCampaignReport, request/deletePaymentMethodQRCode*, and the API-key `publicGetOrderOffer`/`publicGetOrderReceipt` fields on the public-orders Lambda, where `identity` is null under API_KEY auth and is never read) run `lambda_unit_resolver.js`, which mirrors the default whole-context Invoke payload and applies the same `__isError` detection. The pipeline functions invoking decorated handlers detect `__isError` too: `delete_campaign_orders_lambda_fn.js` util.errors to abort the deleteCampaign pipeline so `delete_campaign` never runs after a failed order-deletion step, `delete_profile_cascade_fn.js`, `confirm_qr_upload_fn.js`, and `batch_qr_urls_fn.js` util.error, the best-effort `delete_payment_method_qr_code_fn.js` swallows it exactly like `ctx.error`, and the best-effort public-orders mailer step `send_public_order_emails_fn.js` swallows both invoke-level `ctx.error` and a structured `__isError`, reporting `confirmationEmailSent: false` instead of failing the order. The generic path logs an error naming the function and returns `INTERNAL_ERROR`; use `@lambda_handler(error_message="...")` for a client-facing message. Only the generic unexpected-exception path is covered — handler-specific typed errors (e.g. retryable `RESOURCE_BUSY` in `_raise_batch_lookup_error`) must stay in the handler/helpers, which is why those helpers' tests still assert `pytest.raises(AppError)`. All AppSync resolver handlers are migrated (#337); handler modules import it under `TYPE_CHECKING` for mypy (relative) with an absolute/relative runtime fallback (see `admin_dispatcher.py`). The Cognito triggers are deliberately NOT decorated: `pre_signup.py` must propagate `FederatedIdentityLinkedException` and original error messages to Cognito, and `post_authentication.py` deliberately swallows exceptions and returns the event so auth never fails on DynamoDB issues. They still log through the project's structured logger — `get_logger(__name__)` from `src/utils/logging.py` (absolute import with a relative fallback) with message + keyword fields, never `logging.getLogger()` at module scope, which mutates the root logger and emits plain-text lines that are not machine-parseable (#565). The single canonical error-code vocabulary is `ErrorCode` in `src/utils/errors.py`; JS resolvers and VTL templates should emit those exact strings (e.g. `INVALID_INPUT`, `NOT_FOUND`, `FORBIDDEN`, `UNAUTHORIZED`, `INTERNAL_ERROR`), not HTTP-style spellings like `BadRequest` (#329 aligned the HTTP-style spellings; legacy types such as `ConflictException` (also the payment-method optimistic-lock conflict from #433), `CatalogInUse`, `RateLimitExceeded`, and `ALREADY_SHARED` remain in some share/invite/usage resolvers). `UNAUTHORIZED` means "session invalid, sign in again" (frontend `isAuthError`/`mapErrorCodeToMessage`): reserve it for a missing/invalid caller identity, and use `FORBIDDEN` for a failed ownership/permission condition on an already-authenticated caller (#439, #571). Overload stub lines in `src/utils/handlers.py` carry `pragma: no cover` (type-checking-only); the module must stay at 100% coverage.

### DynamoDB lookup failures: one classification, and never a silent partial answer (#291, #456, #556)

Expand Down
Loading