# CLAUDE.md - Instructions for AI Assistants

This file provides context for AI assistants working on the zae-limiter codebase.

## Project Overview

zae-limiter is a rate limiting library backed by DynamoDB using the token bucket algorithm. It excels at scenarios where:
- Multiple limits are tracked per call (rpm, tpm)
- Consumption is unknown upfront (adjust after the operation completes)
- Hierarchical limits exist (API key → project, tenant → user)
- Cost matters (~$0.75/1M requests)

**Project scopes:** `limiter`, `bucket`, `cli`, `infra`, `ci`, `aggregator`, `models`, `schema`, `repository`, `lease`, `exceptions`, `cache`, `test`, `benchmark`, `local`. See `release-planning.md` for area labels.

## Build & Development

### Using uv (preferred)

```bash
# Setup (one-time)
uv sync --all-extras
pre-commit install  # Install git hooks for linting

# Deploy infrastructure (CloudFormation)
uv run zae-limiter deploy --name limiter --region us-east-1

# Run tests
uv run pytest

# Type check
uv run mypy src/zae_limiter

# Lint (or let pre-commit run automatically on commit)
uv run ruff check --fix .
uv run ruff format .

# Run all pre-commit hooks manually
pre-commit run --all-files

# Lint CloudFormation template (after modifying cfn_template.yaml)
uv run cfn-lint src/zae_limiter/infra/cfn_template.yaml
```

### Using conda

```bash
# Setup (one-time)
conda create -n zae-limiter python=3.12
conda activate zae-limiter
pip install -e ".[dev]"
pytest
```

## Infrastructure Deployment

### CloudFormation Stack

The library uses CloudFormation for infrastructure deployment. The `deploy` command automatically:
1. Creates CloudFormation stack with DynamoDB table, streams, and Lambda function
2. Packages and deploys the Lambda aggregator code from the installed package

```bash
# Basic deployment
zae-limiter deploy --name my-app --region us-east-1

# Deploy to LocalStack
zae-limiter deploy --name my-app --endpoint-url http://localhost:4566 --region us-east-1

# Deploy without aggregator Lambda
zae-limiter deploy --name my-app --no-aggregator

# Enterprise deployment (permission boundary + custom role naming)
zae-limiter deploy --name my-app \
  --permission-boundary arn:aws:iam::aws:policy/PowerUserAccess \
  --role-name-format "pb-{}-PowerUser"

# Other deploy flags: --lambda-timeout, --lambda-memory, --log-retention-days,
# --alarm-sns-topic, --no-alarms, --no-audit-archival, --enable-tracing, --no-iam-roles

# Stack management
zae-limiter status --name my-app --region us-east-1
zae-limiter delete --name my-app --yes

# Export for custom deployment
zae-limiter cfn-template > template.yaml
zae-limiter lambda-export --output lambda.zip
```

**Lambda Deployment Details:**
- The CLI automatically builds a deployment package using `aws-lambda-builders` for cross-platform compatibility
- Only `[lambda]` extra dependencies (aws-lambda-powertools) are pip-installed; `boto3` is provided by the Lambda runtime
- The `zae_limiter_aggregator` package and a minimal `zae_limiter/schema.py` stub are copied into the zip
- Lambda code is updated via AWS Lambda API after stack creation
- No S3 bucket required - deployment package is uploaded directly
- No Docker required - `aws-lambda-builders` handles platform-specific wheels

### Declarative Infrastructure (Recommended)

Pass `StackOptions` to declare the desired state:

```python
from zae_limiter import RateLimiter, StackOptions

# Basic deployment
limiter = RateLimiter(
    name="my-app",
    region="us-east-1",
    stack_options=StackOptions(),  # CloudFormation ensures state matches
)

# Enterprise deployment with permission boundary and custom role naming
limiter = RateLimiter(
    name="my-app",
    region="us-east-1",
    stack_options=StackOptions(
        permission_boundary="arn:aws:iam::aws:policy/PowerUserAccess",
        role_name_format="pb-{}-PowerUser",
    ),
)
```

Other `StackOptions` fields: `lambda_memory`, `retention_days`, `enable_alarms`, `alarm_sns_topic`, `enable_audit_archival`, `audit_archive_glacier_days`, `enable_tracing`, `create_iam_roles`.

**When to use `StackOptions` vs CLI:**
- **StackOptions**: Self-contained apps, serverless deployments, minimal onboarding friction
- **CLI**: Strict infra/app separation, audit requirements, Terraform/CDK integration

### Local Development with LocalStack

LocalStack provides full AWS service emulation (CloudFormation, DynamoDB, Streams, Lambda). Use the `zae-limiter local` CLI commands (preferred) or `docker-compose.yml`:

```bash
# Start LocalStack with CLI (preferred — source of truth for container config)
zae-limiter local up

# Start and deploy a stack in one step
zae-limiter local up --name my-app

# Check status / stream logs / stop
zae-limiter local status
zae-limiter local logs --follow
zae-limiter local down
```

**Important:** The Docker socket mount is required for LocalStack to spawn Lambda functions as Docker containers. Without it, CloudFormation stack creation will fail when the aggregator Lambda is enabled.

**Note:** CloudFormation is used for all deployments, including LocalStack. The `endpoint_url` parameter configures the AWS endpoint for all services. See `localstack-parity.md` for keeping CLI, `docker-compose.yml`, and CI in sync.

## Project Structure

```
src/zae_limiter/
├── __init__.py        # Public API exports
├── models.py          # Limit, Entity, LimitStatus, BucketState, StackOptions, AuditEvent, AuditAction, UsageSnapshot, UsageSummary, LimiterInfo, BackendCapabilities, Status, LimitName, ResourceCapacity, EntityCapacity
├── exceptions.py      # RateLimitExceeded, RateLimiterUnavailable, StackCreationError, VersionError, ValidationError, EntityNotFoundError, InfrastructureNotFoundError
├── naming.py          # Resource name validation (ZAEL- prefix retained for legacy discovery)
├── bucket.py          # Token bucket math (integer arithmetic)
├── schema.py          # DynamoDB key builders
├── repository_protocol.py # RepositoryProtocol for backend abstraction
├── repository.py      # DynamoDB operations
├── lease.py           # Lease context manager
├── limiter.py         # RateLimiter, SyncRateLimiter
├── config_cache.py    # Client-side config caching with TTL (CacheStats)
├── cli.py             # CLI commands (deploy, delete, status, list, cfn-template, lambda-export, version, upgrade, check, audit, usage, entity, resource, system, local)
├── version.py         # Version tracking and compatibility
├── migrations/        # Schema migration framework
│   └── __init__.py    # Migration registry and runner
├── visualization/     # Usage snapshot formatting and display
│   ├── __init__.py    # UsageFormatter enum, format_usage_snapshots()
│   ├── factory.py     # Formatter factory
│   ├── formatters.py  # PlotFormatter (ASCII charts)
│   └── table.py       # TableFormatter for tabular output
└── infra/
    ├── stack_manager.py    # CloudFormation stack operations
    ├── lambda_builder.py   # Lambda deployment package builder
    ├── discovery.py        # Multi-stack discovery and listing
    └── cfn_template.yaml   # CloudFormation template

src/zae_limiter_aggregator/   # Lambda aggregator (top-level package)
├── __init__.py               # Re-exports handler, processor types
├── handler.py                # Lambda entry point
├── processor.py              # Stream processing logic for usage snapshots
└── archiver.py               # S3 audit archival (gzip JSONL)
```

### Repository Pattern (v0.5.0+)

The `Repository` class owns data access and infrastructure management. `RateLimiter` owns business logic.

```python
from zae_limiter import RateLimiter, Repository, StackOptions

# New pattern (preferred): Repository with stack_options
repo = Repository(
    name="my-app",
    region="us-east-1",
    stack_options=StackOptions(lambda_memory=512),  # Pass to constructor
)
await repo.ensure_infrastructure()  # Creates stack using stored options
limiter = RateLimiter(repository=repo)

# Old pattern (deprecated, emits DeprecationWarning):
# repo.create_stack(stack_options=StackOptions())  # Don't use

# RateLimiter._ensure_initialized() calls repo.ensure_infrastructure() automatically
```

**Key API methods:**
- `Repository(stack_options=...)` - Pass infrastructure config to constructor
- `repo.ensure_infrastructure()` - Create/update stack using stored options (no-op if None)
- `repo.create_stack()` - **Deprecated**. Will be removed in v2.0.0

See [ADR-108](docs/adr/108-repository-protocol.md) and [ADR-110](docs/adr/110-deprecation-constructor.md) for details.

## Naming Convention

### Resource Naming

Users provide a short identifier (e.g., `my-app`), and the system uses it directly as the stack and resource name:

| User Provides | AWS Resources |
|---------------|---------------|
| `limiter` | `limiter` (stack, table, Lambda, etc.) |
| `my-app` | `my-app` (stack, table, Lambda, etc.) |

**Key points:**
- Stack name = user-provided name directly
- Table name = stack name
- Lambda function name: `{name}-aggregator`
- DLQ name: `{name}-aggregator-dlq`
- IAM roles: `{name}-app-role`, `{name}-admin-role`, `{name}-readonly-role`
- Log group: `/aws/lambda/{name}-aggregator`
- S3 audit archive bucket name is auto-generated by CloudFormation
- The `name` parameter is cloud-agnostic (not tied to CloudFormation terminology)
- Names must use **hyphens** (not underscores) due to CloudFormation rules
- Names must start with a letter and contain only alphanumeric characters and hyphens
- Maximum identifier length: 38 characters (IAM role name constraints)

**Invalid names (rejected by validation):**
- `rate_limits` (underscores not allowed)
- `my.app` (periods not allowed)
- `123app` (must start with letter)

### Hot Partition Risk Mitigation (Issue #116)

Cascade (`cascade=True`) causes parent entities to receive traffic proportional to child count. High-fanout parents (1000+ children) risk exceeding per-partition throughput (~3,000 RCU / 1,000 WCU).

**Decision tree:**
- 0-500 children with cascade: Safe, no action needed
- 500-1000 children with cascade: Monitor with Contributor Insights
- 1000+ children with cascade: Implement write sharding (see [Performance Guide](docs/performance.md#write-sharding-for-high-fanout-parents)) or disable cascade

Primary mitigation: cascade defaults to `False`.

## Key Design Decisions

### Integer Arithmetic for Precision
- All token values stored as **millitokens** (x1000)
- Refill rates stored as fraction: `refill_amount / refill_period_seconds`
- Avoids floating point precision issues in distributed systems

### Token Bucket Algorithm
- Buckets can go **negative** for post-hoc reconciliation
- Refill is calculated lazily on each access
- `burst >= capacity` allows controlled bursting

### DynamoDB Single Table Design
- All entities, buckets, limits, usage in one table
- GSI1: Parent -> Children lookups
- GSI2: Resource aggregation (capacity tracking)
- Uses TransactWriteItems for atomicity

### Exception Design
- `RateLimitExceeded` includes **ALL** limit statuses
- Both `violations` (exceeded) and `passed` (ok) are available
- `retry_after_seconds` calculated from primary bottleneck

## Common Tasks

### Adding a New Limit Type
1. No code changes needed - `Limit.custom()` supports any configuration
2. For convenience, add factory method to `Limit` class in `models.py`

### Modifying the Schema
1. Update key builders in `schema.py`
2. Update serialization in `repository.py`
3. Update CloudFormation template in `infra/cfn_template.yaml`
4. Be careful with backwards compatibility

### Adding New Exception Fields
1. Update `LimitStatus` in `models.py`
2. Update `RateLimitExceeded.as_dict()` in `exceptions.py`
3. Update tests in `test_limiter.py`

## Documentation

### Docs Framework

The project uses **MkDocs Material** for documentation. Configuration is in `mkdocs.yml`.

```bash
# Preview docs locally (--livereload required due to Click 8.3.x bug)
uv run mkdocs serve --livereload --dirty
```

Use **Mermaid** for all diagrams (MkDocs Material has built-in support).

### Docs Structure

Documentation is organized by **audience** with 4 top-level sections:

```
docs/
├── index.md                 # Landing page
├── getting-started.md       # Installation, first deployment
│
├── guide/                   # User Guide (library users)
│   ├── basic-usage.md       # Rate limiting patterns, error handling
│   ├── hierarchical.md      # Parent/child entities, cascade mode
│   ├── llm-integration.md   # Token estimation and reconciliation
│   └── unavailability.md    # Error handling strategies
│
├── infra/                   # Operator Guide (ops/platform teams)
│   ├── deployment.md        # CLI deployment, declarative infrastructure
│   ├── production.md        # Security, multi-region, cost
│   ├── cloudformation.md    # Template customization
│   └── auditing.md          # Audit logging and compliance
├── operations/              # Troubleshooting runbooks
├── monitoring.md            # Dashboards, alerts, Logs Insights
├── performance.md           # Capacity planning, optimization
├── migrations.md            # Schema migrations
│
├── cli.md                   # Reference: CLI commands
├── api/                     # Reference: API documentation
│
└── contributing/            # Contributors (developers)
    ├── index.md             # Quick start, links to CLAUDE.md
    ├── development.md       # Environment setup, code quality
    ├── localstack.md        # Local AWS development (developer-only)
    ├── testing.md           # Test organization, pytest fixtures
    └── architecture.md      # DynamoDB schema, token bucket
```

**Key organization decisions:**
- **LocalStack is developer-only** - lives in `contributing/`, not `infra/`
- **User Guide** = how to use the library (rate limiting, hierarchies, LLM integration)
- **Operator Guide** = how to run in production (deployment, monitoring, performance)
- **Contributing** = how to develop the library (setup, testing, architecture)
- **CLAUDE.md remains the authoritative dev reference** - Contributing docs are lightweight entry points

## Important Invariants

1. **Lease commits only on success**: If any exception occurs in the context, changes are rolled back
2. **Bucket can go negative**: `lease.adjust()` never throws, allows debt
3. **Cascade is per-entity config**: Set `cascade=True` on `create_entity()` to auto-cascade to parent on every `acquire()`
4. **Stored limits are the default (v0.5.0+)**: Limits resolved from System/Resource/Entity config automatically. Pass `limits` parameter to override.
5. **Transactions are atomic**: Multi-entity updates succeed or fail together
6. **Transaction item limit**: DynamoDB `TransactWriteItems` supports max 100 items per transaction. Cascade operations with many buckets (entity + parent, multiple resources x limits) must stay within this limit

## DynamoDB Access Patterns

| Pattern | Query |
|---------|-------|
| Get entity | `PK=ENTITY#{id}, SK=#META` |
| Get buckets | `PK=ENTITY#{id}, SK begins_with #BUCKET#` |
| Batch get buckets | `BatchGetItem` with multiple PK/SK pairs (issue #133) |
| Get children | GSI1: `GSI1PK=PARENT#{id}` |
| Resource capacity | GSI2: `GSI2PK=RESOURCE#{name}, SK begins_with BUCKET#` |
| List resources with defaults | `PK=SYSTEM#, SK=#RESOURCES` (single GetItem: 1 RCU, issue #233) |
| Get version | `PK=SYSTEM#, SK=#VERSION` |
| Get audit events | `PK=AUDIT#{entity_id}, SK begins_with #AUDIT#` |
| Get usage snapshots (by entity) | `PK=ENTITY#{id}, SK begins_with #USAGE#` |
| Get usage snapshots (by resource) | GSI2: `GSI2PK=RESOURCE#{name}, GSI2SK begins_with USAGE#` |
| Get system limits | `PK=SYSTEM#, SK begins_with #LIMIT#` |
| Get resource limits | `PK=RESOURCE#{resource}, SK begins_with #LIMIT#` |
| Get entity limits | `PK=ENTITY#{id}, SK begins_with #LIMIT#{resource}#` |

**Optimized read patterns (issue #133):**
- `acquire()` uses `BatchGetItem` to fetch all buckets for entity + parent in a single round trip
- This reduces cascade scenarios from N sequential GetItem calls to 1 BatchGetItem call

**Hot partition risk with cascade (issue #116):** See [Hot Partition Risk Mitigation](#hot-partition-risk-mitigation-issue-116) above.

**Key builders for config records:**
- `pk_system()` - Returns `SYSTEM#`
- `pk_resource(resource)` - Returns `RESOURCE#{resource}`
- `pk_entity(entity_id)` - Returns `ENTITY#{entity_id}`
- `sk_system_limit(limit_name)` - Returns `#LIMIT#{limit_name}` (no resource)
- `sk_resource_limit(limit_name)` - Returns `#LIMIT#{limit_name}` (no resource in SK since PK has it)
- `sk_limit(resource, limit_name)` - Returns `#LIMIT#{resource}#{limit_name}`

**Audit entity IDs for config levels** (ADR-106):
- System config: Audit events use `$SYSTEM` as entity_id
- Resource config: Audit events use `$RESOURCE:{resource_name}` (e.g., `$RESOURCE:gpt-4`)

### Centralized Configuration (v0.5.0+)

Limit configs use a three-level hierarchy with precedence: **Entity > Resource > System > Constructor defaults**.

**API methods for managing stored limits:**

| Level | Set | Get | Delete | List |
|-------|-----|-----|--------|------|
| System | `set_system_defaults(limits, on_unavailable)` | `get_system_defaults()` | `delete_system_defaults()` | - |
| Resource | `set_resource_defaults(resource, limits)` | `get_resource_defaults(resource)` | `delete_resource_defaults(resource)` | `list_resources_with_defaults()` |
| Entity | `set_limits(entity_id, limits, resource)` | `get_limits(entity_id, resource)` | `delete_limits(entity_id, resource)` | - |

**CLI commands for managing stored limits:**

```bash
# System-level defaults
zae-limiter system set-defaults -l tpm:100000 -l rpm:1000 --on-unavailable allow

# Resource-level limits
zae-limiter resource set-defaults gpt-4 -l tpm:50000 -l rpm:500

# Entity-level limits (highest precedence)
zae-limiter entity set-limits user-123 --resource gpt-4 -l rpm:1000
```

Each level also has `get-*` and `delete-*` subcommands. Use `zae-limiter resource list` to list resources with defaults.

Config fields are stored alongside limits in existing `#LIMIT#` records:

| Level | PK | SK | Purpose |
|-------|----|----|---------|
| System | `SYSTEM#` | `#LIMIT#{limit_name}` | Global defaults (all resources) |
| Resource | `RESOURCE#{resource}` | `#LIMIT#{resource}#{limit_name}` | Resource-specific |
| Entity | `ENTITY#{id}` | `#LIMIT#{resource}#{limit_name}` | Entity-specific (existing) |

**Config fields:**
- `config_version` (int): Atomic counter for cache invalidation
- `on_unavailable` (string): "allow" or "block"
- `auto_update` (bool): Auto-update Lambda on version mismatch
- `strict_version` (bool): Fail on version mismatch

**Caching:** 60s TTL in-memory cache per RateLimiter instance (configurable via `config_cache_ttl` parameter, 0 to disable). Use `invalidate_config_cache()` for immediate refresh. Use `get_cache_stats()` for monitoring. Negative caching for entities without custom config.

**Cost impact:** 3 RCU per cache miss (one per level). With caching, `acquire()` costs 1-2 RCU per request regardless of limit count (O(1) via composite bucket items, ADR-114/115).

### Schema Design Notes

All records use flat schema (v0.6.0+, top-level attributes, no nested `data.M`). See `dynamodb-patterns.md` rules and [ADR-111](docs/adr/111-flatten-all-records.md).

See [ADR-100](docs/adr/100-centralized-config.md) for full config design details.

## Dependencies

**Required:**
- `aioboto3`: Async DynamoDB client
- `aws-lambda-builders`: Cross-platform Lambda packaging (see ADR-113)
- `boto3`: Sync DynamoDB (for Lambda aggregator)
- `pip`: Required by `aws-lambda-builders` for dependency resolution

**Optional extras:**
- `[plot]`: `asciichartpy` for ASCII chart visualization of usage snapshots
- `[dev]`: Testing and development tools (pytest, moto, ruff, mypy, pre-commit)
- `[docs]`: MkDocs documentation generation
- `[cdk]`: AWS CDK constructs
- `[lambda]`: Lambda Powertools (aws-lambda-powertools)

## Releasing

Releases are fully automated via GitHub Actions (`release.yml`). No manual build or publish steps required.

**Process:** Tag a version on main (`git tag v0.1.0 && git push origin v0.1.0`), and GitHub Actions builds, generates changelog (git-cliff), creates a GitHub Release, and publishes to PyPI via OIDC.

**Version management:** Versions are automatically generated from git tags using `hatch-vcs`. No manual version updates needed. Tag format: `v{major}.{minor}.{patch}`.

**Changelog:** Uses `git-cliff` with `cliff.toml` config. Parses conventional commits since the last tag.
