# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Package Overview

DynamoDB snapshot store for offboarding automation. Stores and retrieves the Phase 1 discovery snapshot that hands off to Phase 2, keyed by Jira ticket ID. Each item carries a TTL so stale records auto-expire.

## Running tests

```bash
cd lambda/dynamo_cli && make test                                            # Unit tests with coverage (uv run pytest tests/unit --cov dynamo_client)
cd lambda/dynamo_cli && uv run pytest tests/unit/                            # Unit tests only
cd lambda/dynamo_cli && uv run pytest tests/unit/test_app.py                 # Handler tests
cd lambda/dynamo_cli && uv run pytest tests/unit/test_dynamo_client.py       # Client tests
cd lambda/dynamo_cli && make docker_test                                     # Lint + unit tests in Docker (scripts/lint-and-test.sh)
```

## Lint and format

```bash
cd lambda/dynamo_cli && make lint         # ruff check
cd lambda/dynamo_cli && make lint_fix     # ruff check --fix
cd lambda/dynamo_cli && make format       # ruff format --check
cd lambda/dynamo_cli && make format_fix   # ruff format
```

`scripts/lint-and-test.sh` additionally runs `yamllint` and `mypy dynamo_client/`.

## CLI usage

The `dynamo-cli` console script (`project.scripts` -> `dev:app`, a Typer app) mirrors the Lambda actions for local use:

```bash
cd lambda/dynamo_cli && uv run dynamo-cli put-snapshot --ticket-id SYS-123 --payload-file snapshot.json
cd lambda/dynamo_cli && uv run dynamo-cli put-snapshot --ticket-id SYS-123 --payload-file snapshot.json --dry-run
cd lambda/dynamo_cli && uv run dynamo-cli get-snapshot --ticket-id SYS-123
cd lambda/dynamo_cli && uv run dynamo-cli delete-snapshot --ticket-id SYS-123
cd lambda/dynamo_cli && uv run dynamo-cli delete-snapshot --ticket-id SYS-123 --dry-run
```

The `--payload-file` JSON is validated against the `Snapshot` schema (`ticket_id` is injected from `--ticket-id`).

## Lambda actions

The handler in `dynamo_client/app.py` dispatches on `event["action"]` (`app.py:67-78`):

| Action | Description | Write? |
|---|---|---|
| `put-snapshot` | Store the Phase 1 discovery snapshot; overwrites any existing item for the same `ticket_id` | Yes |
| `get-snapshot` | Retrieve a stored snapshot by `ticket_id` | No |
| `delete-snapshot` | Delete a snapshot after Phase 2 completes | Yes |

An unknown or missing action raises `ValueError` (`app.py:78`). `put-snapshot` and `delete-snapshot` accept `dry_run: true`, which skips the write and returns `stored=false` / `deleted=false` (`app.py:90-94`, `app.py:142-146`).

## Event / response shapes

Defined as Pydantic models in `shared/schemas/dynamo.py` and re-exported from `shared.schemas`.

### `Snapshot` (`dynamo.py:11-26`)
Frozen Phase 1 discovery results for one ticket:

| Field | Type | Notes |
|---|---|---|
| `ticket_id` | `str` | Partition key |
| `email` | `str` | |
| `full_name` | `str` | |
| `last_working_day` | `str` | |
| `auth0_matches` | `list[Auth0Match]` | defaults `[]` |
| `terraform_hits` | `list[TerraformHit]` | defaults `[]` |
| `auth0_action` | `Literal['delete', 'suspend']` | Phase 2 Auth0 op; defaults `delete` |
| `checked_at` | `str` | Set by handler to current UTC ISO timestamp (`app.py:104`) |

### put-snapshot (`dynamo.py:29-48`)
- `PutSnapshotEvent`: `action` (`Literal['put-snapshot']`), `ticket_id`, `email`, `full_name`, `last_working_day`, `auth0_matches=[]`, `terraform_hits=[]`, `auth0_action='delete'`, `dry_run=False`.
- `PutSnapshotResponse`: `ticket_id`, `stored` (bool), `dry_run` (bool).

### get-snapshot (`dynamo.py:51-63`)
- `GetSnapshotEvent`: `action` (`Literal['get-snapshot']`), `ticket_id`.
- `GetSnapshotResponse`: `ticket_id`, `found` (bool), `snapshot` (`Snapshot | None`, defaults `None`).

### delete-snapshot (`dynamo.py:66-79`)
- `DeleteSnapshotEvent`: `action` (`Literal['delete-snapshot']`), `ticket_id`, `dry_run=False`.
- `DeleteSnapshotResponse`: `ticket_id`, `deleted` (bool), `dry_run` (bool).

## Key Architecture

### Source layout
- `dynamo_client/` — the package (`app.py` handler, `dynamo_client.py` client). This is the deployed package (`ruff`/`mypy`/`--cov` all target `dynamo_client`).
- `config.py`, `dev.py` — top-level modules (`tool.setuptools.py-modules`). `dev.py` is the local Typer CLI; `config.py` is the settings module.
- `shared/schemas/` — the shared schema package, mounted into the image from `../shared` (`Dockerfile:40`, `docker-compose.yaml` `additional_contexts: shared: ../shared`).
- Lambda handler entrypoint: `dynamo_client.app.handler` (`Dockerfile:97,121`).

### DynamoDB client (`dynamo_client/dynamo_client.py`)
`DynamoClient(table_name, region)` wraps a `boto3.resource('dynamodb').Table` (`dynamo_client.py:31-34`). Methods:

- `put_snapshot(snapshot, ttl_days=7)` — `put_item`; computes `ttl` as `now + ttl_days*86400` (Unix epoch) and adds it to the item; overwrites any existing item with the same `ticket_id` (`dynamo_client.py:36-49`). The handler passes `ttl_days=cfg.dynamodb_ttl_days` (`app.py:107`), so the effective default is 30 (see config), not the method default of 7.
- `get_snapshot(ticket_id)` — `get_item` on `Key={'ticket_id': ...}`; returns `None` if absent, else strips the DynamoDB-only `ttl` field and validates into a `Snapshot` (`dynamo_client.py:51-65`).
- `delete_snapshot(ticket_id)` — `delete_item` on `Key={'ticket_id': ...}`; idempotent (deleting a non-existent item is a no-op) (`dynamo_client.py:67-75`).

Hash (partition) key is `ticket_id`. The `ttl` attribute is a Unix-epoch timestamp DynamoDB uses to auto-expire items; it is not part of the `Snapshot` schema and is stripped on read.

### Handler singletons (`dynamo_client/app.py`)
`DynamoConfig` and `DynamoClient` are module-level singletons created lazily on first use, so they persist across warm invocations within a Lambda container (`app.py:34-56`).

### Configuration (`config.py`)
`DynamoConfig` is a `pydantic_settings.BaseSettings`, overridable via environment variables:

| Setting | Env var | Default |
|---|---|---|
| `dynamodb_table_name` | `DYNAMODB_TABLE_NAME` | `offboarding-automation-snapshots` |
| `dynamodb_ttl_days` | `DYNAMODB_TTL_DAYS` | `30` |
| `aws_region` | `AWS_REGION` | `us-east-1` |
| `environment` | `ENVIRONMENT` | `local` |

For DynamoDB Local, set `AWS_ENDPOINT_URL=http://localhost:8000` (`config.py:9-11`).

### Behavioral notes
- **Overwrite on put** — `put-snapshot` replaces any existing item for the same `ticket_id` (last write wins).
- **Get returns `found`** — a missing record returns `found=False` with `snapshot=None`; the handler docstring notes callers (Step Functions) should treat this as a safety abort signal (`app.py:114-131`).
- **Idempotent delete** — deleting a non-existent snapshot succeeds.
- **Dry run** — `put-snapshot`/`delete-snapshot` short-circuit before any DynamoDB write when `dry_run=true`.

### Observability
Sentry is initialised at import with the `AwsLambdaIntegration` (`app.py:23-27`). The deploy image ships the Datadog Lambda extension and uses `datadog_lambda.handler.handler` wrapping `DD_LAMBDA_HANDLER=dynamo_client.app.handler` (`Dockerfile:103-126`).
