"""DynamoDB repository for rate limiter data."""

import asyncio
import logging
import random
import time
import warnings
from typing import TYPE_CHECKING, Any, cast

import aioboto3
from botocore.exceptions import ClientError
from ulid import ULID

from . import schema
from .config_cache import CacheStats, ConfigCache, ConfigSource
from .exceptions import EntityExistsError
from .models import (
    AuditAction,
    AuditEvent,
    BackendCapabilities,
    BucketState,
    Entity,
    Limit,
    OnUnavailableAction,
    StackOptions,
    UsageSnapshot,
    UsageSummary,
    validate_identifier,
    validate_resource,
)
from .naming import normalize_stack_name
from .repository_protocol import SpeculativeFailureReason, SpeculativeResult

if TYPE_CHECKING:
    from .repository_builder import RepositoryBuilder

logger = logging.getLogger(__name__)


class Repository:
    """Async DynamoDB repository for rate limiter data.

    Handles all DynamoDB operations including entities, buckets,
    limit configs, and transactions.

    Use :meth:`open` to open a repository (recommended), or :meth:`builder`
    for custom infrastructure options (permission boundaries, IAM config).

    .. deprecated::
        Direct construction via ``Repository(...)`` is deprecated and will be
        removed in v1.0.0. Use ``Repository.open(...)`` or
        ``Repository.builder().build()`` instead.

    Args:
        name: Resource identifier (e.g., "my-app"). Used as the
            CloudFormation stack_name and DynamoDB table_name.
        region: AWS region (e.g., "us-east-1").
        endpoint_url: Custom endpoint URL (e.g., LocalStack).
        stack_options: Configuration for CloudFormation infrastructure.
            Pass StackOptions to enable declarative infrastructure management.
        config_cache_ttl: TTL in seconds for config cache (default: 60, 0 to disable).
            Controls caching of resolved limit configs in resolve_limits().

    Example::

        # Most users (auto-provisions if needed)
        repo = await Repository.open("my-app")

        # Custom infrastructure options
        repo = await Repository.builder().namespace("my-app").build()
    """

    def __init__(
        self,
        name: str,
        region: str | None = None,
        endpoint_url: str | None = None,
        stack_options: StackOptions | None = None,
        config_cache_ttl: int = 60,
        *,
        _skip_deprecation_warning: bool = False,
    ) -> None:
        if not _skip_deprecation_warning:
            warnings.warn(
                "Directly calling Repository(...) is deprecated. "
                "Use Repository.open(...) for most use cases "
                "or Repository.builder().build() for custom infrastructure options. "
                "This will be removed in v1.0.0.",
                DeprecationWarning,
                stacklevel=2,
            )
        # Validate and normalize name
        self.stack_name = normalize_stack_name(name)
        # Table name is always identical to stack name
        self.table_name = self.stack_name
        self.region = region
        self.endpoint_url = endpoint_url
        self._namespace_id = schema.DEFAULT_NAMESPACE
        self._namespace_name = "default"
        self._bucket_ttl_refill_multiplier = 7
        self._stack_options = stack_options
        self._session: aioboto3.Session | None = None
        self._client: Any = None
        self._caller_identity_arn: str | None = None
        self._caller_identity_fetched = False
        self._audit_retention_days_cache: int | None = None

        # Builder-initialized flag: set True by RepositoryBuilder.build()
        self._builder_initialized = False
        # Auto-update Lambda on version mismatch (set by builder)
        self._auto_update = True
        # Scoped repo flag: prevents close() from closing shared client
        self._is_scoped = False

        # DynamoDB supports all extended features
        self._capabilities = BackendCapabilities(
            supports_audit_logging=True,
            supports_usage_snapshots=True,
            supports_infrastructure_management=True,
            supports_change_streams=True,
            supports_batch_operations=True,
        )

        # Config cache for resolve_limits() (ADR-122)
        self._config_cache = ConfigCache(
            ttl_seconds=config_cache_ttl, namespace_id=self._namespace_id
        )
        self._config_cache_ttl = config_cache_ttl

        # Entity metadata cache for parallel cascade writes (issue #318)
        # Value: (cascade, parent_id, {resource: shard_count})
        # cascade/parent_id are immutable; shard_count updated on doubling
        self._entity_cache: dict[tuple[str, str], tuple[bool, str | None, dict[str, int]]] = {}

        # Cached on_unavailable from system config (issue #366)
        # Once loaded, used as fallback when DynamoDB is unreachable
        self._on_unavailable_cache: OnUnavailableAction | None = None

        # Namespace resolution cache: shared across scoped repos
        self._namespace_cache: dict[str, str] = {}

    @classmethod
    def builder(cls) -> "RepositoryBuilder":
        """Create a RepositoryBuilder for fluent configuration.

        For most use cases, prefer :meth:`open` instead. Use ``builder()``
        when you need custom infrastructure options (permission boundaries,
        Lambda config, IAM role naming).

        Stack defaults mirror :meth:`open`: ``ZAEL_STACK`` env var
        or ``"zae-limiter"``, namespace ``"default"``.

        Example:
            repo = await (
                Repository.builder()
                .namespace("my-app")
                .lambda_memory(512)
                .build()
            )
        """
        from .repository_builder import RepositoryBuilder

        return RepositoryBuilder()

    @classmethod
    async def open(
        cls,
        namespace: str | None = None,
        *,
        stack: str | None = None,
        region: str | None = None,
        endpoint_url: str | None = None,
        config_cache_ttl: int = 60,
        auto_update: bool = True,
    ) -> "Repository":
        """Open a repository, auto-provisioning infrastructure if needed.

        This is the recommended entry point for most applications.
        Namespace is the primary parameter — stack name defaults to
        ``"zae-limiter"`` and is rarely needed.

        **Auto-provision behavior:**

        - If the DynamoDB table doesn't exist, deploys a new stack with
          default options (aggregator enabled).
        - If the table exists but the namespace isn't registered, registers it.
        - The ``"default"`` namespace is always registered.
        - Version check and Lambda auto-update run on every call.

        For custom infrastructure options (permission boundaries, Lambda
        config, IAM role naming), use ``Repository.builder()`` instead.

        **Stack resolution:** ``stack`` arg → ``ZAEL_STACK`` env var
        → ``"zae-limiter"``.

        **Namespace resolution:** ``namespace`` arg → ``ZAEL_NAMESPACE``
        env var → ``"default"``.

        Args:
            namespace: Namespace to open. Defaults to ``ZAEL_NAMESPACE``
                env var or ``"default"``.
            stack: Stack name. Defaults to ``ZAEL_STACK`` env var
                or ``"zae-limiter"``.
            region: AWS region (e.g., ``"us-east-1"``).
            endpoint_url: Custom endpoint URL (e.g., LocalStack).
            config_cache_ttl: Config cache TTL in seconds (default: 60,
                0 to disable).
            auto_update: Auto-update Lambda on version mismatch
                (default: True).

        Returns:
            Fully initialized Repository ready for use.

        Raises:
            IncompatibleSchemaError: If schema migration is required.
            VersionMismatchError: If auto_update is False and versions differ.

        Example::

            # Most users
            repo = await Repository.open("my-app")

            # Multi-tenant
            repo_alpha = await Repository.open("tenant-alpha")

            # Explicit stack
            repo = await Repository.open("my-app", stack="custom-stack")

            # Simplest (stack="zae-limiter", namespace="default")
            repo = await Repository.open()
        """
        from .naming import resolve_namespace_name, resolve_stack_name

        name = resolve_stack_name(stack)
        ns_name = resolve_namespace_name(namespace)

        repo = cls(
            name=name,
            region=region,
            endpoint_url=endpoint_url,
            config_cache_ttl=config_cache_ttl,
            _skip_deprecation_warning=True,
        )
        repo._auto_update = auto_update

        # Try resolve namespace — auto-provision if needed
        try:
            namespace_id = await repo._resolve_namespace(ns_name)
        except ClientError as e:
            if e.response["Error"]["Code"] == "ResourceNotFoundException":
                # Table doesn't exist — deploy stack with defaults
                repo._stack_options = StackOptions()
                await repo._ensure_infrastructure_internal()
                # Always register "default" namespace
                await repo._register_namespace("default")
                # Register requested namespace (no-op if "default")
                namespace_id = await repo._register_namespace(ns_name)
            else:
                raise
        else:
            if namespace_id is None:
                # Table exists but namespace not found — register it
                namespace_id = await repo._register_namespace(ns_name)

        repo._namespace_id = namespace_id
        repo._namespace_name = ns_name
        repo._reinitialize_config_cache(namespace_id)

        # Version check + Lambda auto-update (always, no endpoint_url guard)
        if auto_update:
            await repo._check_and_update_version_auto()
        else:
            await repo._check_version_strict()

        repo._builder_initialized = True
        return repo

    @property
    def namespace_name(self) -> str:
        """The human-readable namespace name."""
        return self._namespace_name

    @property
    def namespace_id(self) -> str:
        """The opaque namespace ID used in DynamoDB keys."""
        return self._namespace_id

    @property
    def capabilities(self) -> BackendCapabilities:
        """Declare which extended features this backend supports."""
        return self._capabilities

    async def _get_client(self) -> Any:
        """Get or create the DynamoDB client."""
        if self._client is None:
            self._session = aioboto3.Session()
            self._client = await self._session.client(
                "dynamodb",
                region_name=self.region,
                endpoint_url=self.endpoint_url,
            ).__aenter__()
        return self._client

    async def namespace(
        self,
        name: str,
        *,
        on_unavailable: "OnUnavailableAction | None" = None,
        bucket_ttl_multiplier: int | None = None,
    ) -> "Repository":
        """Return a scoped Repository for the given namespace.

        The scoped repo shares the DynamoDB client, entity cache, and
        namespace cache with the parent, but has its own ``ConfigCache``
        and namespace identity.  Calling ``close()`` on a scoped repo
        is a no-op (it does not close the shared client).

        Args:
            name: Namespace name to resolve.
            on_unavailable: Override on_unavailable behavior for this
                namespace ("allow" or "block").  Persisted via
                ``set_system_defaults()``.
            bucket_ttl_multiplier: Override bucket TTL multiplier for
                this scoped repo.  Defaults to the parent's value.

        Returns:
            A new Repository scoped to the resolved namespace.

        Raises:
            NamespaceNotFoundError: If the namespace is not registered.
        """
        from .exceptions import NamespaceNotFoundError

        # Resolve namespace (uses cache if available)
        namespace_id = await self._resolve_namespace(name)
        if namespace_id is None:
            raise NamespaceNotFoundError(name)

        # Create scoped repo (shallow copy sharing client resources)
        scoped = Repository.__new__(Repository)
        scoped.stack_name = self.stack_name
        scoped.table_name = self.table_name
        scoped.region = self.region
        scoped.endpoint_url = self.endpoint_url
        scoped._namespace_id = namespace_id
        scoped._namespace_name = name
        scoped._bucket_ttl_refill_multiplier = (
            bucket_ttl_multiplier
            if bucket_ttl_multiplier is not None
            else self._bucket_ttl_refill_multiplier
        )
        scoped._stack_options = None  # scoped repos don't manage infrastructure
        scoped._session = self._session
        scoped._client = self._client
        scoped._caller_identity_arn = self._caller_identity_arn
        scoped._caller_identity_fetched = self._caller_identity_fetched
        scoped._audit_retention_days_cache = self._audit_retention_days_cache
        scoped._builder_initialized = self._builder_initialized
        scoped._auto_update = self._auto_update
        scoped._is_scoped = True
        scoped._capabilities = self._capabilities
        scoped._config_cache_ttl = self._config_cache_ttl
        scoped._config_cache = ConfigCache(
            ttl_seconds=self._config_cache_ttl, namespace_id=namespace_id
        )
        # Share mutable caches
        scoped._entity_cache = self._entity_cache
        scoped._namespace_cache = self._namespace_cache
        # Scoped repos start with no on_unavailable cache (each namespace
        # has its own system config)
        scoped._on_unavailable_cache = None

        # Persist on_unavailable as system config if set
        if on_unavailable is not None:
            existing_limits, _ = await scoped.get_system_defaults()
            await scoped.set_system_defaults(
                limits=existing_limits,
                on_unavailable=on_unavailable,
            )

        return scoped

    async def close(self) -> None:
        """Close the DynamoDB client.

        No-op for scoped repos (created via ``namespace()``).
        """
        if self._is_scoped:
            return
        if self._client is not None:
            await self._client.__aexit__(None, None, None)
            self._client = None
            self._session = None

    async def _get_item(self, pk: str, sk: str) -> dict[str, Any] | None:
        """Get a raw DynamoDB item by primary key (testing helper).

        Args:
            pk: Partition key value (already formatted, e.g., "ENTITY#user-1")
            sk: Sort key value (already formatted, e.g., "#BUCKET#api")

        Returns:
            Deserialized item dict or None if not found.
        """
        client = await self._get_client()
        response = await client.get_item(
            TableName=self.table_name,
            Key={"PK": {"S": pk}, "SK": {"S": sk}},
        )
        item = response.get("Item")
        if not item:
            return None
        # Deserialize DynamoDB types to Python types (S and N only for buckets)
        result: dict[str, Any] = {}
        for key, value in item.items():
            if "S" in value:
                result[key] = value["S"]
            elif "N" in value:
                result[key] = int(value["N"])
        return result

    async def _get_caller_identity_arn(self) -> str | None:
        """
        Get the ARN of the AWS caller identity (lazy cached).

        Returns the full ARN of the IAM user/role making API calls.
        Returns None if the identity cannot be determined (e.g., local testing).
        """
        if self._caller_identity_fetched:
            return self._caller_identity_arn

        self._caller_identity_fetched = True
        try:
            if self._session is None:
                self._session = aioboto3.Session()

            async with self._session.client(
                "sts",
                region_name=self.region,
                endpoint_url=self.endpoint_url,
            ) as sts_client:
                response = await sts_client.get_caller_identity()
                self._caller_identity_arn = response.get("Arn")
        except Exception:
            # Silently fail - caller identity is optional
            self._caller_identity_arn = None

        return self._caller_identity_arn

    def _now_ms(self) -> int:
        """Current time in milliseconds."""
        return int(time.time() * 1000)

    # -------------------------------------------------------------------------
    # Table operations
    # -------------------------------------------------------------------------

    async def create_table(self) -> None:
        """Create the DynamoDB table if it doesn't exist."""
        client = await self._get_client()
        definition = schema.get_table_definition(self.table_name)

        try:
            await client.create_table(**definition)
            # Wait for table to be active
            waiter = client.get_waiter("table_exists")
            await waiter.wait(TableName=self.table_name)
        except ClientError as e:
            if e.response["Error"]["Code"] != "ResourceInUseException":
                raise

    async def delete_table(self) -> None:
        """Delete the DynamoDB table."""
        client = await self._get_client()
        try:
            await client.delete_table(TableName=self.table_name)
        except ClientError as e:
            if e.response["Error"]["Code"] != "ResourceNotFoundException":
                raise

    async def delete_stack(self) -> None:
        """Delete the CloudFormation stack and all associated resources.

        Permanently removes the stack including DynamoDB table, Lambda
        aggregator, IAM roles, and CloudWatch log groups. Waits for
        deletion to complete. No-op if stack doesn't exist.

        Raises:
            StackCreationError: If deletion fails.
        """
        from .infra.stack_manager import StackManager

        async with StackManager(self.stack_name, self.region, self.endpoint_url) as manager:
            await manager.delete_stack(self.stack_name)

    async def ensure_infrastructure(self) -> None:
        """
        Ensure DynamoDB infrastructure exists.

        .. deprecated::
            Use ``Repository.builder(...).build()`` instead, which handles
            infrastructure creation during the build step.

        Creates CloudFormation stack using stack_options passed to the constructor.
        No-op if stack_options was not provided.

        Raises:
            StackCreationError: If CloudFormation stack creation fails
        """
        import warnings

        warnings.warn(
            "ensure_infrastructure() is deprecated. Use Repository.builder(...).build() instead.",
            DeprecationWarning,
            stacklevel=2,
        )
        await self._ensure_infrastructure_internal()

    async def _ensure_infrastructure_internal(self) -> None:
        """Internal: ensure infrastructure exists (no deprecation warning)."""
        if self._stack_options is None:
            return

        from .infra.stack_manager import StackManager

        async with StackManager(self.stack_name, self.region, self.endpoint_url) as manager:
            await manager.create_stack(stack_options=self._stack_options)

            # Deploy Lambda code if aggregator is enabled
            if self._stack_options.enable_aggregator:
                await manager.deploy_lambda_code()

        # Write retention config to system config item
        await self._write_audit_retention_config()

    async def create_stack(
        self,
        stack_options: StackOptions | None = None,
    ) -> None:
        """
        Create DynamoDB infrastructure via CloudFormation.

        .. deprecated:: 0.6.0
            Use :meth:`ensure_infrastructure` instead. Pass stack_options
            to the Repository constructor. Will be removed in v1.0.0.

        Args:
            stack_options: Configuration for CloudFormation stack.
                If None, uses the stack_options passed to the constructor.

        Raises:
            StackCreationError: If CloudFormation stack creation fails
        """
        import warnings

        warnings.warn(
            "create_stack() is deprecated. Use ensure_infrastructure() instead. "
            "Pass stack_options to the Repository constructor. "
            "This will be removed in v1.0.0.",
            DeprecationWarning,
            stacklevel=2,
        )

        if stack_options is not None:
            # Temporarily override stack_options for this call
            saved = self._stack_options
            self._stack_options = stack_options
            try:
                await self._ensure_infrastructure_internal()
            finally:
                self._stack_options = saved
        else:
            await self._ensure_infrastructure_internal()

    # -------------------------------------------------------------------------
    # Namespace registry (minimal, inline — #369 adds full CRUD + CLI)
    # -------------------------------------------------------------------------

    async def _register_namespace(self, name: str) -> str:
        """Register a namespace (idempotent).

        Creates two records under RESERVED_NAMESPACE:
        - ``PK=_/SYSTEM#, SK=#NAMESPACE#{name}`` (name → ID lookup)
        - ``PK=_/SYSTEM#, SK=#NSID#{id}`` (ID → name lookup)

        Uses TransactWriteItems with ConditionExpression to ensure atomicity.
        On TransactionCanceledException (namespace exists), resolves and returns
        the existing ID.

        Returns:
            The namespace_id (either newly created or existing).
        """
        import secrets

        client = await self._get_client()
        namespace_id = secrets.token_urlsafe(8)
        while namespace_id.startswith("-"):
            namespace_id = secrets.token_urlsafe(8)
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        try:
            await client.transact_write_items(
                TransactItems=[
                    {
                        "Put": {
                            "TableName": self.table_name,
                            "Item": {
                                "PK": {"S": pk},
                                "SK": {"S": schema.sk_namespace(name)},
                                "namespace_id": {"S": namespace_id},
                                "namespace_name": {"S": name},
                                "status": {"S": "active"},
                                "created_at": {"S": now},
                                # GSI4: co-located under reserved namespace
                                "GSI4PK": {"S": schema.RESERVED_NAMESPACE},
                                "GSI4SK": {"S": pk},
                            },
                            "ConditionExpression": "attribute_not_exists(PK)",
                        }
                    },
                    {
                        "Put": {
                            "TableName": self.table_name,
                            "Item": {
                                "PK": {"S": pk},
                                "SK": {"S": schema.sk_nsid(namespace_id)},
                                "namespace_id": {"S": namespace_id},
                                "namespace_name": {"S": name},
                                "status": {"S": "active"},
                                "created_at": {"S": now},
                                # GSI4: co-located under reserved namespace
                                "GSI4PK": {"S": schema.RESERVED_NAMESPACE},
                                "GSI4SK": {"S": pk},
                            },
                            "ConditionExpression": "attribute_not_exists(PK)",
                        }
                    },
                ]
            )
        except ClientError as e:
            if e.response["Error"]["Code"] == "TransactionCanceledException":
                # Namespace already exists — resolve the existing ID
                existing_id = await self._resolve_namespace(name)
                if existing_id is not None:
                    return existing_id
                # Should not happen: transaction failed but record not found
                raise  # pragma: no cover
            raise

        # Cache the resolved namespace
        self._namespace_cache[name] = namespace_id
        return namespace_id

    async def _resolve_namespace(self, name: str) -> str | None:
        """Resolve a namespace name to its opaque ID.

        Returns None if the namespace doesn't exist or has status "deleted".
        """
        # Check cache first
        if name in self._namespace_cache:
            return self._namespace_cache[name]

        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_namespace(name)},
            },
        )

        item = response.get("Item")
        if not item:
            return None

        status = item.get("status", {}).get("S", "")
        if status in ("deleted", "purging"):
            return None

        namespace_id: str = item["namespace_id"]["S"]
        self._namespace_cache[name] = namespace_id
        return namespace_id

    # -------------------------------------------------------------------------
    # Namespace registry — public API (#369)
    # -------------------------------------------------------------------------

    async def register_namespace(self, namespace: str) -> str:
        """Register a namespace in the registry (idempotent).

        Creates forward (name -> ID) and reverse (ID -> name) mappings.
        Idempotent: returns existing ID if namespace already registered.

        Args:
            namespace: Namespace name to register.

        Returns:
            The namespace_id (either newly created or existing).

        Raises:
            ValueError: If namespace is the reserved namespace ``"_"``.
        """
        if namespace == schema.RESERVED_NAMESPACE:
            raise ValueError(
                f"Namespace name {schema.RESERVED_NAMESPACE!r} is reserved for system use"
            )
        return await self._register_namespace(namespace)

    async def register_namespaces(self, namespaces: list[str]) -> dict[str, str]:
        """Bulk-register multiple namespaces.

        Registers all namespaces in DynamoDB (forward + reverse records each).

        Args:
            namespaces: List of namespace names to register.

        Returns:
            Mapping of ``{name: namespace_id}`` for all namespaces.

        Raises:
            ValueError: If any namespace is the reserved namespace ``"_"``.
        """
        # Validate all names upfront (fail fast)
        for ns in namespaces:
            if ns == schema.RESERVED_NAMESPACE:
                raise ValueError(
                    f"Namespace name {schema.RESERVED_NAMESPACE!r} is reserved for system use"
                )

        ids = await asyncio.gather(*[self._register_namespace(ns) for ns in namespaces])
        return dict(zip(namespaces, ids))

    async def get_namespace(self, namespace: str) -> dict[str, str] | None:
        """Get details for a single namespace by name.

        Args:
            namespace: Namespace name to look up.

        Returns:
            Dict with ``{name, namespace_id, status, created_at}``
            or ``None`` if not found.
        """
        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_namespace(namespace)},
            },
        )
        item = response.get("Item")
        if not item:
            return None

        return {
            "name": namespace,
            "namespace_id": item["namespace_id"]["S"],
            "status": item.get("status", {}).get("S", "unknown"),
            "created_at": item.get("created_at", {}).get("S", "unknown"),
        }

    async def list_namespaces(self) -> list[dict[str, str]]:
        """List all active namespaces with their IDs.

        Performs a Query on ``PK = "_/SYSTEM#"`` with
        ``SK begins_with "#NAMESPACE#"`` (forward records only).

        Returns:
            List of ``{name, namespace_id, created_at}`` dicts.
        """
        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        results: list[dict[str, str]] = []
        exclusive_start_key = None

        while True:
            query_params: dict[str, Any] = {
                "TableName": self.table_name,
                "KeyConditionExpression": "PK = :pk AND begins_with(SK, :sk_prefix)",
                "ExpressionAttributeValues": {
                    ":pk": {"S": pk},
                    ":sk_prefix": {"S": schema.sk_namespace_prefix()},
                },
            }
            if exclusive_start_key is not None:
                query_params["ExclusiveStartKey"] = exclusive_start_key

            response = await client.query(**query_params)

            for item in response.get("Items", []):
                results.append(
                    {
                        "name": item["namespace_name"]["S"],
                        "namespace_id": item["namespace_id"]["S"],
                        "created_at": item.get("created_at", {}).get("S", ""),
                    }
                )

            if "LastEvaluatedKey" not in response:
                break
            exclusive_start_key = response["LastEvaluatedKey"]

        return results

    async def delete_namespace(self, namespace: str) -> None:
        """Soft-delete a namespace. O(1) for data plane.

        Removes the forward record and marks the reverse record as
        ``status="deleted"``.  Data items are NOT deleted — they remain
        orphaned under the namespace's random ID prefix.

        No-op if the namespace does not exist.

        Args:
            namespace: Namespace name to delete.

        Raises:
            ValueError: If namespace is the reserved namespace ``"_"``.
        """
        if namespace == schema.RESERVED_NAMESPACE:
            raise ValueError(
                f"Namespace name {schema.RESERVED_NAMESPACE!r} is reserved for system use"
            )

        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        # Step 1: Read the forward record to get the namespace_id
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_namespace(namespace)},
            },
        )
        item = response.get("Item")
        if not item:
            return  # No-op if namespace does not exist

        namespace_id: str = item["namespace_id"]["S"]
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

        # Step 2: Delete the forward record
        await client.delete_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_namespace(namespace)},
            },
        )

        # Step 3: Update the reverse record — status="deleted", deleted_at
        await client.update_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_nsid(namespace_id)},
            },
            UpdateExpression="SET #status = :deleted, deleted_at = :now",
            ExpressionAttributeNames={"#status": "status"},
            ExpressionAttributeValues={
                ":deleted": {"S": "deleted"},
                ":now": {"S": now},
            },
        )

        # Invalidate cache
        self._namespace_cache.pop(namespace, None)

    async def recover_namespace(self, namespace_id: str) -> str:
        """Recover a deleted namespace by its ID.

        Reads the reverse record to find the original name, re-creates
        the forward record, and marks the reverse record as active.

        Args:
            namespace_id: Opaque namespace ID to recover.

        Returns:
            The recovered namespace name.

        Raises:
            EntityNotFoundError: If the reverse record does not exist.
            ValueError: If the reverse record has ``status="purging"``
                or ``status="active"``.
        """
        from .exceptions import EntityNotFoundError

        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        # Step 1: Read the reverse record
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_nsid(namespace_id)},
            },
        )
        item = response.get("Item")
        if not item:
            raise EntityNotFoundError(namespace_id)

        status = item.get("status", {}).get("S", "")
        namespace_name: str = item["namespace_name"]["S"]

        if namespace_name == schema.RESERVED_NAMESPACE:
            raise ValueError(
                f"Namespace name {schema.RESERVED_NAMESPACE!r} is reserved for system use"
            )

        if status == "purging":
            raise ValueError(
                f"Cannot recover namespace '{namespace_name}' — purge is in progress "
                f"and is terminal"
            )

        if status == "active":
            raise ValueError(f"Namespace '{namespace_name}' is already active (not deleted)")

        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        created_at = item.get("created_at", {}).get("S", now)

        # Step 2: Re-create the forward record
        try:
            await client.put_item(
                TableName=self.table_name,
                Item={
                    "PK": {"S": pk},
                    "SK": {"S": schema.sk_namespace(namespace_name)},
                    "namespace_id": {"S": namespace_id},
                    "namespace_name": {"S": namespace_name},
                    "status": {"S": "active"},
                    "created_at": {"S": created_at},
                    "GSI4PK": {"S": schema.RESERVED_NAMESPACE},
                    "GSI4SK": {"S": pk},
                },
                ConditionExpression="attribute_not_exists(PK)",
            )
        except client.exceptions.ConditionalCheckFailedException:
            raise ValueError(
                f"Cannot recover namespace '{namespace_name}' — "
                f"the name has been re-registered by another caller"
            ) from None

        # Step 3: Update the reverse record — status="active", remove deleted_at
        await client.update_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_nsid(namespace_id)},
            },
            UpdateExpression="SET #status = :active REMOVE deleted_at",
            ExpressionAttributeNames={"#status": "status"},
            ExpressionAttributeValues={
                ":active": {"S": "active"},
            },
        )

        # Update cache
        self._namespace_cache[namespace_name] = namespace_id
        return namespace_name

    async def list_orphan_namespaces(self) -> list[dict[str, str]]:
        """List deleted namespaces with orphaned data.

        Queries reverse records (``SK begins_with "#NSID#"``) and
        filters for ``status="deleted"``.

        Returns:
            List of ``{namespace_id, namespace, deleted_at}`` dicts.
        """
        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        results: list[dict[str, str]] = []
        exclusive_start_key = None

        while True:
            query_params: dict[str, Any] = {
                "TableName": self.table_name,
                "KeyConditionExpression": "PK = :pk AND begins_with(SK, :sk_prefix)",
                "ExpressionAttributeValues": {
                    ":pk": {"S": pk},
                    ":sk_prefix": {"S": schema.sk_nsid_prefix()},
                },
            }
            if exclusive_start_key is not None:
                query_params["ExclusiveStartKey"] = exclusive_start_key

            response = await client.query(**query_params)

            for item in response.get("Items", []):
                status = item.get("status", {}).get("S", "")
                if status == "deleted":
                    results.append(
                        {
                            "namespace_id": item["namespace_id"]["S"],
                            "namespace": item["namespace_name"]["S"],
                            "deleted_at": item.get("deleted_at", {}).get("S", ""),
                        }
                    )

            if "LastEvaluatedKey" not in response:
                break
            exclusive_start_key = response["LastEvaluatedKey"]

        return results

    async def purge_namespace(self, namespace_id: str) -> None:
        """Purge all orphaned data for a deleted namespace.

        Verifies the namespace is in ``"deleted"`` status, transitions to
        ``"purging"``, queries GSI4 to find all items, deletes them in
        batches, then removes the reverse record.

        Safe to call on a non-existent namespace_id (no-op).

        Args:
            namespace_id: Opaque namespace ID to purge.

        Raises:
            ValueError: If the namespace is ``"active"`` (cannot purge
                an active namespace).
        """
        client = await self._get_client()
        pk = schema.pk_system(schema.RESERVED_NAMESPACE)

        # Step 1: Read the reverse record
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_nsid(namespace_id)},
            },
        )
        item = response.get("Item")
        if not item:
            return  # No-op for non-existent namespace_id

        status = item.get("status", {}).get("S", "")
        if status == "active":
            raise ValueError(
                f"Cannot purge active namespace '{item['namespace_name']['S']}'. "
                f"Delete it first with delete_namespace()."
            )

        # Step 2: Set status="purging" (prevents concurrent recovery)
        if status != "purging":
            await client.update_item(
                TableName=self.table_name,
                Key={
                    "PK": {"S": pk},
                    "SK": {"S": schema.sk_nsid(namespace_id)},
                },
                UpdateExpression="SET #status = :purging",
                ExpressionAttributeNames={"#status": "status"},
                ExpressionAttributeValues={
                    ":purging": {"S": "purging"},
                },
            )

        # Step 3: Query GSI4 for all items belonging to the namespace
        exclusive_start_key = None
        while True:
            query_params: dict[str, Any] = {
                "TableName": self.table_name,
                "IndexName": schema.GSI4_NAME,
                "KeyConditionExpression": "GSI4PK = :pk",
                "ExpressionAttributeValues": {
                    ":pk": {"S": namespace_id},
                },
            }
            if exclusive_start_key is not None:
                query_params["ExclusiveStartKey"] = exclusive_start_key

            response = await client.query(**query_params)
            items = response.get("Items", [])

            # Step 4: Batch delete items (25 per batch), retrying unprocessed
            if items:
                delete_requests = [
                    {"DeleteRequest": {"Key": {"PK": i["PK"], "SK": i["SK"]}}} for i in items
                ]
                for batch_start in range(0, len(delete_requests), 25):
                    chunk = delete_requests[batch_start : batch_start + 25]
                    unprocessed: list[Any] = chunk
                    while unprocessed:
                        resp = await client.batch_write_item(
                            RequestItems={self.table_name: unprocessed}
                        )
                        unprocessed = resp.get("UnprocessedItems", {}).get(self.table_name, [])

            if "LastEvaluatedKey" not in response:
                break
            exclusive_start_key = response["LastEvaluatedKey"]

        # Step 5: Delete the reverse record
        await client.delete_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": pk},
                "SK": {"S": schema.sk_nsid(namespace_id)},
            },
        )

    def _reinitialize_config_cache(self, namespace_id: str) -> None:
        """Reinitialize the config cache with a new namespace_id."""
        self._config_cache = ConfigCache(
            ttl_seconds=self._config_cache_ttl, namespace_id=namespace_id
        )

    # -------------------------------------------------------------------------
    # Version management (used by builder; replaces limiter-level version check)
    # -------------------------------------------------------------------------

    async def _check_and_update_version_auto(self) -> None:
        """Check version compatibility and auto-update Lambda if needed.

        Used when ``auto_update=True``. On version mismatch, deploys
        updated Lambda code and updates the version record. On schema
        migration needed, raises ``IncompatibleSchemaError``.
        """
        from . import __version__
        from .version import InfrastructureVersion, check_compatibility

        version_record = await self.get_version_record()

        if version_record is None:
            await self._initialize_version_record()
            return

        infra_version = InfrastructureVersion.from_record(version_record)
        compatibility = check_compatibility(__version__, infra_version)

        if compatibility.is_compatible and not compatibility.requires_lambda_update:
            return

        if compatibility.requires_schema_migration:
            from .exceptions import IncompatibleSchemaError

            raise IncompatibleSchemaError(
                client_version=__version__,
                schema_version=infra_version.schema_version,
                message=compatibility.message,
            )

        if compatibility.requires_lambda_update:
            await self._perform_lambda_update()

    async def _check_version_strict(self) -> None:
        """Check version compatibility in strict mode (no auto-update).

        Raises ``VersionMismatchError`` if the Lambda version differs
        from the client version.
        """
        from . import __version__
        from .exceptions import VersionMismatchError
        from .version import InfrastructureVersion, check_compatibility

        version_record = await self.get_version_record()

        if version_record is None:
            await self._initialize_version_record()
            return

        infra_version = InfrastructureVersion.from_record(version_record)
        compatibility = check_compatibility(__version__, infra_version)

        if compatibility.is_compatible and not compatibility.requires_lambda_update:
            return

        if compatibility.requires_schema_migration:
            from .exceptions import IncompatibleSchemaError

            raise IncompatibleSchemaError(
                client_version=__version__,
                schema_version=infra_version.schema_version,
                message=compatibility.message,
            )

        if compatibility.requires_lambda_update:
            raise VersionMismatchError(
                client_version=__version__,
                schema_version=infra_version.schema_version,
                lambda_version=infra_version.lambda_version,
                message=compatibility.message,
                can_auto_update=True,
            )

    async def _initialize_version_record(self) -> None:
        """Initialize the version record for first-time setup."""
        from . import __version__
        from .version import get_schema_version

        await self.set_version_record(
            schema_version=get_schema_version(),
            lambda_version=__version__,
            client_min_version="0.0.0",
            updated_by=f"client:{__version__}",
        )

    async def _perform_lambda_update(self) -> None:
        """Update Lambda code to match client version."""
        from . import __version__
        from .infra.stack_manager import StackManager
        from .version import get_schema_version

        async with StackManager(
            self.stack_name,
            self.region,
            self.endpoint_url,
        ) as manager:
            await manager.deploy_lambda_code()

            await self.set_version_record(
                schema_version=get_schema_version(),
                lambda_version=__version__,
                client_min_version="0.0.0",
                updated_by=f"client:{__version__}",
            )

    # -------------------------------------------------------------------------
    # Entity operations
    # -------------------------------------------------------------------------

    async def create_entity(
        self,
        entity_id: str,
        name: str | None = None,
        parent_id: str | None = None,
        cascade: bool = False,
        metadata: dict[str, str] | None = None,
        principal: str | None = None,
    ) -> Entity:
        """
        Create a new entity.

        Args:
            entity_id: Unique identifier for the entity
            name: Optional display name (defaults to entity_id)
            parent_id: Optional parent entity ID (for hierarchical limits)
            cascade: If True, acquire() will also consume from parent entity
            metadata: Optional key-value metadata
            principal: Caller identity for audit logging

        Returns:
            The created Entity

        Raises:
            InvalidIdentifierError: If entity_id or parent_id is invalid
            EntityExistsError: If entity already exists
        """
        # Validate inputs at API boundary
        validate_identifier(entity_id, "entity_id")
        if parent_id is not None:
            validate_identifier(parent_id, "parent_id")

        client = await self._get_client()
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

        item: dict[str, Any] = {
            "PK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
            "SK": {"S": schema.sk_meta()},
            "entity_id": {"S": entity_id},
            "name": {"S": name or entity_id},
            "parent_id": {"S": parent_id} if parent_id else {"NULL": True},
            "cascade": {"BOOL": cascade},
            "metadata": {"M": self._serialize_map(metadata or {})},
            "created_at": {"S": now},
            # GSI4: namespace-scoped item discovery
            "GSI4PK": {"S": self._namespace_id},
            "GSI4SK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
        }

        # Add GSI1 keys for parent lookup if this is a child
        if parent_id:
            item["GSI1PK"] = {"S": schema.gsi1_pk_parent(self._namespace_id, parent_id)}
            item["GSI1SK"] = {"S": schema.gsi1_sk_child(entity_id)}

        try:
            await client.put_item(
                TableName=self.table_name,
                Item=item,
                ConditionExpression="attribute_not_exists(PK)",
            )
        except ClientError as e:
            if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
                raise EntityExistsError(entity_id)
            raise

        # Log audit event
        await self._log_audit_event(
            action=AuditAction.ENTITY_CREATED,
            entity_id=entity_id,
            principal=principal,
            details={
                "name": name or entity_id,
                "parent_id": parent_id,
                "cascade": cascade,
                "metadata": metadata or {},
            },
        )

        return Entity(
            id=entity_id,
            name=name or entity_id,
            parent_id=parent_id,
            cascade=cascade,
            metadata=metadata or {},
            created_at=now,
        )

    async def get_entity(self, entity_id: str) -> Entity | None:
        """Get an entity by ID."""
        client = await self._get_client()

        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
                "SK": {"S": schema.sk_meta()},
            },
        )

        item = response.get("Item")
        cache_key = (self._namespace_id, entity_id)
        existing_shards = self._entity_cache.get(cache_key, (False, None, {}))[2]
        if not item:
            self._entity_cache[cache_key] = (False, None, existing_shards)
            return None

        entity = self._deserialize_entity(item)
        self._entity_cache[cache_key] = (entity.cascade, entity.parent_id, existing_shards)
        return entity

    async def delete_entity(
        self,
        entity_id: str,
        principal: str | None = None,
    ) -> None:
        """
        Delete an entity and all its related records.

        Args:
            entity_id: ID of the entity to delete
            principal: Caller identity for audit logging
        """
        client = await self._get_client()

        # Query entity items (metadata, config, usage, audit)
        response = await client.query(
            TableName=self.table_name,
            KeyConditionExpression="PK = :pk",
            ExpressionAttributeValues={
                ":pk": {"S": schema.pk_entity(self._namespace_id, entity_id)}
            },
        )
        items = response.get("Items", [])

        # Query bucket items via GSI3 (pre-shard buckets have separate PKs)
        gsi3_response = await client.query(
            TableName=self.table_name,
            IndexName="GSI3",
            KeyConditionExpression="GSI3PK = :gsi3pk",
            ExpressionAttributeValues={
                ":gsi3pk": {"S": schema.gsi3_pk_entity(self._namespace_id, entity_id)},
            },
        )
        bucket_items = gsi3_response.get("Items", [])

        all_items = items + bucket_items
        if not all_items:
            return

        # Build delete requests
        delete_requests = [
            {"DeleteRequest": {"Key": {"PK": item["PK"], "SK": item["SK"]}}} for item in all_items
        ]

        # BatchWriteItem in chunks of 25
        for i in range(0, len(delete_requests), 25):
            chunk = delete_requests[i : i + 25]
            await client.batch_write_item(RequestItems={self.table_name: chunk})

        # Log audit event
        await self._log_audit_event(
            action=AuditAction.ENTITY_DELETED,
            entity_id=entity_id,
            principal=principal,
            details={"records_deleted": len(all_items)},
        )

    async def get_children(self, parent_id: str) -> list[Entity]:
        """Get all children of a parent entity."""
        client = await self._get_client()

        response = await client.query(
            TableName=self.table_name,
            IndexName=schema.GSI1_NAME,
            KeyConditionExpression="GSI1PK = :pk",
            ExpressionAttributeValues={
                ":pk": {"S": schema.gsi1_pk_parent(self._namespace_id, parent_id)}
            },
        )

        entities = []
        for item in response.get("Items", []):
            entity = self._deserialize_entity(item)
            if entity:
                entities.append(entity)

        return entities

    # -------------------------------------------------------------------------
    # Bucket operations
    # -------------------------------------------------------------------------

    async def get_bucket(
        self,
        entity_id: str,
        resource: str,
        limit_name: str,
        shard_id: int = 0,
    ) -> BucketState | None:
        """Get a single limit's bucket from the composite item."""
        client = await self._get_client()

        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)},
                "SK": {"S": schema.sk_state()},
            },
        )

        item = response.get("Item")
        if not item:
            return None

        buckets = self._deserialize_composite_bucket(item)
        for b in buckets:
            if b.limit_name == limit_name:
                return b
        return None

    async def get_buckets(
        self,
        entity_id: str,
        resource: str | None = None,
        shard_id: int = 0,
    ) -> list[BucketState]:
        """Get all buckets for an entity, optionally filtered by resource.

        With pre-shard buckets (v0.9.0+), each item lives on its own partition
        key ``PK={ns}/BUCKET#{id}#{resource}#{shard}``. When resource is
        specified, fetches the single bucket at the given shard_id. When
        resource is None, uses GSI3 (KEYS_ONLY) to discover all bucket PKs,
        then BatchGetItem to fetch full items.

        The internal ``wcu`` infrastructure limit is filtered from the
        returned bucket states.

        Args:
            entity_id: Entity to query buckets for.
            resource: Resource name filter, or None for all resources.
            shard_id: Shard index (used only when resource is specified).

        Returns:
            List of BucketState objects (one per application limit).
        """
        client = await self._get_client()

        if resource:
            # Single composite item for this entity+resource+shard
            response = await client.get_item(
                TableName=self.table_name,
                Key={
                    "PK": {
                        "S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)
                    },
                    "SK": {"S": schema.sk_state()},
                },
            )
            item = response.get("Item")
            if not item:
                return []
            return [
                b
                for b in self._deserialize_composite_bucket(item)
                if b.limit_name != schema.WCU_LIMIT_NAME
            ]

        # Step 1: GSI3 query to discover bucket PKs (KEYS_ONLY projection)
        key_condition = "GSI3PK = :gsi3pk"
        expression_values: dict[str, Any] = {
            ":gsi3pk": {"S": schema.gsi3_pk_entity(self._namespace_id, entity_id)},
        }

        response = await client.query(
            TableName=self.table_name,
            IndexName="GSI3",
            KeyConditionExpression=key_condition,
            ExpressionAttributeValues=expression_values,
        )

        gsi3_items = response.get("Items", [])
        if not gsi3_items:
            return []

        # Step 2: BatchGetItem to fetch full items from main table
        request_keys = [{"PK": item["PK"], "SK": item["SK"]} for item in gsi3_items]

        buckets: list[BucketState] = []
        for i in range(0, len(request_keys), 100):
            chunk = request_keys[i : i + 100]
            batch_response = await client.batch_get_item(
                RequestItems={self.table_name: {"Keys": chunk}}
            )
            for full_item in batch_response.get("Responses", {}).get(self.table_name, []):
                buckets.extend(self._deserialize_composite_bucket(full_item))
        return [b for b in buckets if b.limit_name != schema.WCU_LIMIT_NAME]

    async def batch_get_buckets(
        self,
        keys: list[tuple[str, str]],
    ) -> dict[tuple[str, str, str], BucketState]:
        """
        Batch get composite buckets in a single DynamoDB call.

        With composite items, each (entity_id, resource, shard) is a single
        DynamoDB item containing all limits. Returns individual BucketStates
        keyed by (entity_id, resource, limit_name) for backward compatibility.

        Args:
            keys: List of (entity_id, resource) tuples. Uses shard_id=0.

        Returns:
            Dict mapping (entity_id, resource, limit_name) to BucketState.
            Missing composite items are not included in the result.

        Note:
            DynamoDB BatchGetItem supports up to 100 items per request.
            For larger batches, this method automatically chunks the requests.
        """
        if not keys:
            return {}

        client = await self._get_client()
        result: dict[tuple[str, str, str], BucketState] = {}

        # Build request keys (deduplicate)
        unique_keys = list(set(keys))

        # BatchGetItem supports max 100 items per request
        for i in range(0, len(unique_keys), 100):
            chunk = unique_keys[i : i + 100]

            request_keys = [
                {
                    "PK": {"S": schema.pk_bucket(self._namespace_id, entity_id, resource, 0)},
                    "SK": {"S": schema.sk_state()},
                }
                for entity_id, resource in chunk
            ]

            response = await client.batch_get_item(
                RequestItems={
                    self.table_name: {
                        "Keys": request_keys,
                    }
                }
            )

            # Process responses — each item is a composite bucket
            items = response.get("Responses", {}).get(self.table_name, [])
            for item in items:
                buckets = self._deserialize_composite_bucket(item)
                for bucket in buckets:
                    key = (bucket.entity_id, bucket.resource, bucket.limit_name)
                    result[key] = bucket

        return result

    async def batch_get_entity_and_buckets(
        self,
        entity_id: str,
        bucket_keys: list[tuple[str, str]],
    ) -> tuple[Entity | None, dict[tuple[str, str, str], BucketState]]:
        """
        Fetch entity metadata and composite buckets in a single BatchGetItem.

        With composite items, each (entity_id, resource) pair is a single
        DynamoDB item. Includes the entity's #META record alongside bucket
        records to avoid a separate get_entity() round trip.

        Args:
            entity_id: Entity whose META record to include
            bucket_keys: List of (entity_id, resource) for composite buckets

        Returns:
            Tuple of (entity_or_none, bucket_dict) where bucket_dict maps
            (entity_id, resource, limit_name) to BucketState.

        Note:
            DynamoDB BatchGetItem supports up to 100 items per request.
            The META key counts toward that limit.
        """
        client = await self._get_client()

        # Build all keys: META key + composite bucket keys
        meta_key = {
            "PK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
            "SK": {"S": schema.sk_meta()},
        }

        request_keys = [meta_key]
        unique_bucket_keys = list(set(bucket_keys))
        for eid, resource in unique_bucket_keys:
            request_keys.append(
                {
                    "PK": {"S": schema.pk_bucket(self._namespace_id, eid, resource, 0)},
                    "SK": {"S": schema.sk_state()},
                }
            )

        entity: Entity | None = None
        buckets: dict[tuple[str, str, str], BucketState] = {}

        # BatchGetItem in chunks of 100
        for i in range(0, len(request_keys), 100):
            chunk = request_keys[i : i + 100]

            response = await client.batch_get_item(
                RequestItems={
                    self.table_name: {
                        "Keys": chunk,
                    }
                }
            )

            items = response.get("Responses", {}).get(self.table_name, [])
            for item in items:
                sk = item.get("SK", {}).get("S", "")
                if sk == schema.sk_meta():
                    entity = self._deserialize_entity(item)
                elif sk.startswith(schema.SK_BUCKET):
                    for bucket in self._deserialize_composite_bucket(item):
                        key = (bucket.entity_id, bucket.resource, bucket.limit_name)
                        buckets[key] = bucket

        # Populate entity cache transparently (issue #318)
        cache_key = (self._namespace_id, entity_id)
        existing_shards = self._entity_cache.get(cache_key, (False, None, {}))[2]
        if entity is not None:
            self._entity_cache[cache_key] = (entity.cascade, entity.parent_id, existing_shards)
        else:
            self._entity_cache[cache_key] = (False, None, existing_shards)

        return entity, buckets

    async def batch_get_configs(
        self,
        keys: list[tuple[str, str]],
    ) -> dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]]:
        """
        Batch get config items in a single DynamoDB call.

        Fetches config records (entity, resource, system level) in a single
        BatchGetItem request and returns deserialized limits.

        Args:
            keys: List of (PK, SK) tuples identifying config items

        Returns:
            Dict mapping (PK, SK) to (limits, on_unavailable) tuples.
            on_unavailable is extracted from system config items (None for others).
            Missing items are not included in the result.

        Note:
            DynamoDB BatchGetItem supports up to 100 items per request.
            For larger batches, this method automatically chunks the requests.
            Uses eventually consistent reads (0.5 RCU per item).
        """
        if not keys:
            return {}

        client = await self._get_client()
        result: dict[tuple[str, str], tuple[list[Limit], OnUnavailableAction | None]] = {}

        # Deduplicate keys
        unique_keys = list(set(keys))

        # BatchGetItem supports max 100 items per request
        for i in range(0, len(unique_keys), 100):
            chunk = unique_keys[i : i + 100]

            request_keys = [
                {
                    "PK": {"S": pk},
                    "SK": {"S": sk},
                }
                for pk, sk in chunk
            ]

            response = await client.batch_get_item(
                RequestItems={
                    self.table_name: {
                        "Keys": request_keys,
                        "ConsistentRead": False,
                    }
                }
            )

            # Process responses: deserialize each item
            items = response.get("Responses", {}).get(self.table_name, [])
            for item in items:
                pk = item.get("PK", {}).get("S", "")
                sk = item.get("SK", {}).get("S", "")
                if pk and sk:
                    limits = self._deserialize_composite_limits(item)
                    ou_attr = item.get("on_unavailable", {})
                    ou_str = ou_attr.get("S") if ou_attr else None
                    on_unavailable: OnUnavailableAction | None = (
                        cast(OnUnavailableAction, ou_str) if ou_str else None
                    )
                    result[(pk, sk)] = (limits, on_unavailable)

        return result

    async def get_or_create_bucket(
        self,
        entity_id: str,
        resource: str,
        limit: Limit,
    ) -> BucketState:
        """
        Get an existing bucket or create a new one with the given limit.

        If the bucket exists, it is returned. If not, a new bucket is created
        with capacity set to the limit's capacity.

        Args:
            entity_id: Entity owning the bucket
            resource: Resource name (e.g., "gpt-4")
            limit: Limit configuration for the bucket

        Returns:
            Existing or newly created BucketState
        """
        existing = await self.get_bucket(entity_id, resource, limit.name)
        if existing is not None:
            return existing

        # Create new bucket at full capacity
        now_ms = self._now_ms()
        state = BucketState(
            entity_id=entity_id,
            resource=resource,
            limit_name=limit.name,
            tokens_milli=limit.capacity * 1000,
            last_refill_ms=now_ms,
            capacity_milli=limit.capacity * 1000,
            refill_amount_milli=limit.refill_amount * 1000,
            refill_period_ms=limit.refill_period_seconds * 1000,
            total_consumed_milli=0,
        )

        # Write bucket to DynamoDB
        put_item = self.build_bucket_put_item(state)
        await self.transact_write([put_item])

        return state

    def build_bucket_put_item(
        self,
        state: BucketState,
        ttl_seconds: int = 86400,
    ) -> dict[str, Any]:
        """Build a PutItem for a composite bucket (for use in transactions).

        Wraps build_composite_create for backward compatibility with protocol.
        """
        now_ms = self._now_ms()
        return self.build_composite_create(
            entity_id=state.entity_id,
            resource=state.resource,
            states=[state],
            now_ms=now_ms,
            ttl_seconds=ttl_seconds,
        )

    def build_bucket_update_item(
        self,
        entity_id: str,
        resource: str,
        limit_name: str,
        new_tokens_milli: int,
        new_last_refill_ms: int,
        expected_tokens_milli: int | None = None,
        shard_id: int = 0,
    ) -> dict[str, Any]:
        """Build an UpdateItem for a single limit in a composite bucket.

        Legacy method — prefer build_composite_normal/retry/adjust for
        composite writes. This updates one limit's tk within the composite item.
        """
        tk_attr = schema.bucket_attr(limit_name, schema.BUCKET_FIELD_TK)
        update: dict[str, dict[str, Any]] = {
            "Update": {
                "TableName": self.table_name,
                "Key": {
                    "PK": {
                        "S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)
                    },
                    "SK": {"S": schema.sk_state()},
                },
                "UpdateExpression": "SET #tokens = :tokens, #refill = :refill",
                "ExpressionAttributeNames": {
                    "#tokens": tk_attr,
                    "#refill": schema.BUCKET_FIELD_RF,
                },
                "ExpressionAttributeValues": {
                    ":tokens": {"N": str(new_tokens_milli)},
                    ":refill": {"N": str(new_last_refill_ms)},
                },
            }
        }

        # Add optimistic locking condition if provided
        if expected_tokens_milli is not None:
            update["Update"]["ConditionExpression"] = "#tokens = :expected"
            update["Update"]["ExpressionAttributeValues"][":expected"] = {
                "N": str(expected_tokens_milli)
            }

        return update

    # -------------------------------------------------------------------------
    # Composite bucket write paths (ADR-114, ADR-115)
    # -------------------------------------------------------------------------

    def build_composite_create(
        self,
        entity_id: str,
        resource: str,
        states: list[BucketState],
        now_ms: int,
        ttl_seconds: int | None = 86400,
        cascade: bool = False,
        parent_id: str | None = None,
        shard_id: int = 0,
        shard_count: int = 1,
    ) -> dict[str, Any]:
        """Build a PutItem for creating a new composite bucket.

        Used on first acquire for an entity+resource. Condition ensures no
        concurrent creation race (attribute_not_exists).

        Args:
            entity_id: Entity owning the bucket
            resource: Resource name
            states: BucketState objects for each limit
            now_ms: Current timestamp in milliseconds
            ttl_seconds: TTL in seconds from now, or None to omit TTL
            cascade: Whether the entity has cascade enabled
            parent_id: The entity's parent_id (if any)
            shard_id: Shard index for this bucket (default 0)
            shard_count: Total number of shards (default 1)
        """
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)},
            "SK": {"S": schema.sk_state()},
            "entity_id": {"S": entity_id},
            "resource": {"S": resource},
            schema.BUCKET_FIELD_RF: {"N": str(now_ms)},
            "GSI2PK": {"S": schema.gsi2_pk_resource(self._namespace_id, resource)},
            "GSI2SK": {"S": schema.gsi2_sk_bucket(entity_id, shard_id)},
            "cascade": {"BOOL": cascade},
            # GSI3: bucket discovery by entity
            "GSI3PK": {"S": schema.gsi3_pk_entity(self._namespace_id, entity_id)},
            "GSI3SK": {"S": schema.gsi3_sk_bucket(resource, shard_id)},
            # GSI4: namespace-scoped item discovery
            "GSI4PK": {"S": self._namespace_id},
            "GSI4SK": {"S": schema.gsi4_sk_bucket(entity_id, resource, shard_id)},
            "shard_count": {"N": str(shard_count)},
        }
        if parent_id is not None:
            item["parent_id"] = {"S": parent_id}
        # Only add TTL if specified (None means no TTL for entity-level config)
        if ttl_seconds is not None:
            item["ttl"] = {"N": str(schema.calculate_ttl(now_ms, ttl_seconds))}

        # Auto-inject wcu infrastructure limit
        wcu_cp_milli = schema.WCU_LIMIT_CAPACITY * 1000
        wcu_ra_milli = schema.WCU_LIMIT_REFILL_AMOUNT * 1000
        wcu_rp_ms = schema.WCU_LIMIT_REFILL_PERIOD_SECONDS * 1000
        wcu_name = schema.WCU_LIMIT_NAME
        item[schema.bucket_attr(wcu_name, schema.BUCKET_FIELD_TK)] = {
            "N": str(wcu_cp_milli),
        }
        item[schema.bucket_attr(wcu_name, schema.BUCKET_FIELD_CP)] = {
            "N": str(wcu_cp_milli),
        }
        item[schema.bucket_attr(wcu_name, schema.BUCKET_FIELD_RA)] = {
            "N": str(wcu_ra_milli),
        }
        item[schema.bucket_attr(wcu_name, schema.BUCKET_FIELD_RP)] = {
            "N": str(wcu_rp_ms),
        }
        item[schema.bucket_attr(wcu_name, schema.BUCKET_FIELD_TC)] = {"N": "0"}

        for state in states:
            name = state.limit_name
            item[schema.bucket_attr(name, schema.BUCKET_FIELD_TK)] = {
                "N": str(state.tokens_milli),
            }
            item[schema.bucket_attr(name, schema.BUCKET_FIELD_CP)] = {
                "N": str(state.capacity_milli),
            }
            item[schema.bucket_attr(name, schema.BUCKET_FIELD_RA)] = {
                "N": str(state.refill_amount_milli),
            }
            item[schema.bucket_attr(name, schema.BUCKET_FIELD_RP)] = {
                "N": str(state.refill_period_ms),
            }
            tc = state.total_consumed_milli if state.total_consumed_milli is not None else 0
            item[schema.bucket_attr(name, schema.BUCKET_FIELD_TC)] = {
                "N": str(tc),
            }

        return {
            "Put": {
                "TableName": self.table_name,
                "Item": item,
                "ConditionExpression": "attribute_not_exists(PK)",
            }
        }

    def build_composite_normal(
        self,
        entity_id: str,
        resource: str,
        consumed: dict[str, int],
        refill_amounts: dict[str, int],
        now_ms: int,
        expected_rf: int,
        ttl_seconds: int | None = None,
        shard_id: int = 0,
    ) -> dict[str, Any]:
        """Build an UpdateItem for the normal write path (ADR-115 path 2).

        ADD tk:(refill - consumed), tc:consumed for each limit.
        SET rf:now. CONDITION rf = :expected.

        Args:
            entity_id: Entity owning the bucket
            resource: Resource name
            consumed: Amount consumed per limit (millitokens)
            refill_amounts: Refill amount per limit (millitokens)
            now_ms: Current timestamp in milliseconds
            expected_rf: Expected refill timestamp for optimistic lock
            ttl_seconds: TTL behavior:
                - None: Don't change TTL
                - 0: REMOVE ttl (entity has custom limits)
                - >0: SET ttl to (now + ttl_seconds)
            shard_id: Shard index for this bucket (default 0)
        """
        add_parts: list[str] = []
        set_parts: list[str] = ["#rf = :now"]
        remove_parts: list[str] = []
        attr_names: dict[str, str] = {"#rf": schema.BUCKET_FIELD_RF}
        attr_values: dict[str, Any] = {
            ":now": {"N": str(now_ms)},
            ":expected_rf": {"N": str(expected_rf)},
        }

        # Handle TTL
        if ttl_seconds is not None:
            attr_names["#ttl"] = "ttl"
            if ttl_seconds > 0:
                # SET ttl to new value
                set_parts.append("#ttl = :ttl_val")
                attr_values[":ttl_val"] = {"N": str(schema.calculate_ttl(now_ms, ttl_seconds))}
            else:
                # REMOVE ttl (entity has custom limits, should persist)
                remove_parts.append("#ttl")

        condition_parts: list[str] = ["#rf = :expected_rf"]

        for name in consumed:
            c = consumed[name]
            r = refill_amounts.get(name, 0)
            tk_delta = r - c  # refill minus consumption

            tk_alias = f"#b_{name}_tk"
            tc_alias = f"#b_{name}_tc"
            tk_val = f":b_{name}_tk_delta"
            tc_val = f":b_{name}_tc_delta"

            attr_names[tk_alias] = schema.bucket_attr(name, schema.BUCKET_FIELD_TK)
            attr_names[tc_alias] = schema.bucket_attr(name, schema.BUCKET_FIELD_TC)
            attr_values[tk_val] = {"N": str(tk_delta)}
            attr_values[tc_val] = {"N": str(c)}

            add_parts.append(f"{tk_alias} {tk_val}")
            add_parts.append(f"{tc_alias} {tc_val}")

            # Guard against concurrent speculative consumption draining tk.
            # Speculative writes modify tk without touching rf, so the rf lock
            # alone can't detect them. Ensure tk can absorb the net decrease.
            floor = max(0, c - r)
            if floor > 0:
                floor_val = f":b_{name}_tk_floor"
                attr_values[floor_val] = {"N": str(floor)}
                condition_parts.append(f"{tk_alias} >= {floor_val}")

        # Build update expression
        update_expr = f"SET {', '.join(set_parts)} ADD {', '.join(add_parts)}"
        if remove_parts:
            update_expr += f" REMOVE {', '.join(remove_parts)}"

        return {
            "Update": {
                "TableName": self.table_name,
                "Key": {
                    "PK": {
                        "S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)
                    },
                    "SK": {"S": schema.sk_state()},
                },
                "UpdateExpression": update_expr,
                "ConditionExpression": " AND ".join(condition_parts),
                "ExpressionAttributeNames": attr_names,
                "ExpressionAttributeValues": attr_values,
            }
        }

    def build_composite_retry(
        self,
        entity_id: str,
        resource: str,
        consumed: dict[str, int],
        shard_id: int = 0,
    ) -> dict[str, Any]:
        """Build an UpdateItem for the retry write path (ADR-115 path 3).

        Lost optimistic lock — skip refill, only consume.
        ADD tk:(-consumed), tc:consumed for each limit.
        CONDITION: tk >= consumed per limit (prevent negative on acquire).
        """
        add_parts: list[str] = []
        condition_parts: list[str] = []
        attr_names: dict[str, str] = {}
        attr_values: dict[str, Any] = {}

        for name in consumed:
            c = consumed[name]
            tk_alias = f"#b_{name}_tk"
            tc_alias = f"#b_{name}_tc"
            tk_neg_val = f":b_{name}_tk_neg"
            tc_val = f":b_{name}_tc_delta"
            tk_threshold = f":b_{name}_tk_min"

            attr_names[tk_alias] = schema.bucket_attr(name, schema.BUCKET_FIELD_TK)
            attr_names[tc_alias] = schema.bucket_attr(name, schema.BUCKET_FIELD_TC)
            attr_values[tk_neg_val] = {"N": str(-c)}
            attr_values[tc_val] = {"N": str(c)}
            attr_values[tk_threshold] = {"N": str(c)}

            add_parts.append(f"{tk_alias} {tk_neg_val}")
            add_parts.append(f"{tc_alias} {tc_val}")
            condition_parts.append(f"{tk_alias} >= {tk_threshold}")

        update_expr = f"ADD {', '.join(add_parts)}"
        condition_expr = " AND ".join(condition_parts)

        return {
            "Update": {
                "TableName": self.table_name,
                "Key": {
                    "PK": {
                        "S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)
                    },
                    "SK": {"S": schema.sk_state()},
                },
                "UpdateExpression": update_expr,
                "ConditionExpression": condition_expr,
                "ExpressionAttributeNames": attr_names,
                "ExpressionAttributeValues": attr_values,
            }
        }

    def build_composite_adjust(
        self,
        entity_id: str,
        resource: str,
        deltas: dict[str, int],
        shard_id: int = 0,
    ) -> dict[str, Any]:
        """Build an UpdateItem for the adjust write path (ADR-115 path 4).

        Unconditional ADD for post-hoc correction. Can go negative by design.
        Positive delta = consumed more (subtract tokens, add to counter).
        Negative delta = consumed less (add tokens, subtract from counter).
        """
        add_parts: list[str] = []
        attr_names: dict[str, str] = {}
        attr_values: dict[str, Any] = {}

        for name, delta in deltas.items():
            if delta == 0:
                continue
            tk_alias = f"#b_{name}_tk"
            tc_alias = f"#b_{name}_tc"
            tk_val = f":b_{name}_tk_delta"
            tc_val = f":b_{name}_tc_delta"

            attr_names[tk_alias] = schema.bucket_attr(name, schema.BUCKET_FIELD_TK)
            attr_names[tc_alias] = schema.bucket_attr(name, schema.BUCKET_FIELD_TC)
            # delta > 0 means consumed more: subtract from tk, add to tc
            attr_values[tk_val] = {"N": str(-delta)}
            attr_values[tc_val] = {"N": str(delta)}

            add_parts.append(f"{tk_alias} {tk_val}")
            add_parts.append(f"{tc_alias} {tc_val}")

        if not add_parts:
            # Nothing to adjust
            return {}

        update_expr = f"ADD {', '.join(add_parts)}"

        return {
            "Update": {
                "TableName": self.table_name,
                "Key": {
                    "PK": {
                        "S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)
                    },
                    "SK": {"S": schema.sk_state()},
                },
                "UpdateExpression": update_expr,
                "ExpressionAttributeNames": attr_names,
                "ExpressionAttributeValues": attr_values,
            }
        }

    async def transact_write(self, items: list[dict[str, Any]]) -> None:
        """Execute a write, using single-item API when possible to halve WCU cost."""
        if not items:
            return

        client = await self._get_client()

        if len(items) == 1:
            item = items[0]
            if "Put" in item:
                await client.put_item(**item["Put"])
            elif "Update" in item:
                await client.update_item(**item["Update"])
            elif "Delete" in item:
                await client.delete_item(**item["Delete"])
            else:
                await client.transact_write_items(TransactItems=items)
        else:
            await client.transact_write_items(TransactItems=items)

    async def write_each(self, items: list[dict[str, Any]]) -> None:
        """Write items independently without cross-item atomicity (1 WCU each).

        Each item is dispatched as a single PutItem, UpdateItem, or DeleteItem
        call. Use for unconditional writes (e.g., ADD adjustments) where partial
        success is acceptable.
        """
        if not items:
            return

        client = await self._get_client()

        for item in items:
            if "Put" in item:
                await client.put_item(**item["Put"])
            elif "Update" in item:
                await client.update_item(**item["Update"])
            elif "Delete" in item:
                await client.delete_item(**item["Delete"])

    async def speculative_consume(
        self,
        entity_id: str,
        resource: str,
        consume: dict[str, int],
        ttl_seconds: int | None = None,
        shard_id: int | None = None,
    ) -> SpeculativeResult:
        """Attempt speculative UpdateItem with condition check.

        Checks entity cache for cascade metadata. If cache hit + cascade,
        issues child+parent UpdateItems concurrently via asyncio.gather
        and returns nested parent_result.

        When ``shard_id`` is explicitly provided, targets that shard directly
        without cascade logic (used for shard retry).

        Args:
            entity_id: Entity owning the bucket
            resource: Resource name
            consume: Amount per limit (tokens, not milli)
            ttl_seconds: TTL in seconds from now, or None for no TTL change
            shard_id: Explicit shard to target (skips random selection and
                cascade logic). None means auto-select from entity cache.

        Returns:
            SpeculativeResult with:
            - On cache hit + cascade + both succeed: parent_result populated
            - On cache miss or non-cascade: parent_result is None
            - On failure: old_buckets from ALL_OLD (or None if bucket missing)
        """
        # Explicit shard_id: direct single-shard consume (shard retry path)
        if shard_id is not None:
            return await self._speculative_consume_single(
                entity_id, resource, consume, ttl_seconds, shard_id=shard_id
            )

        # Check entity cache for parallel cascade opportunity (issue #318)
        cache_key = (self._namespace_id, entity_id)
        cache_entry = self._entity_cache.get(cache_key)

        # Determine shard from entity cache
        shard_count = 1
        if cache_entry is not None:
            shard_count = cache_entry[2].get(resource, 1)
        effective_shard_id = random.randrange(shard_count) if shard_count > 1 else 0

        if cache_entry is not None:
            cascade_cached, parent_id_cached, shards_cached = cache_entry
            if cascade_cached and parent_id_cached:
                child_result: SpeculativeResult
                parent_result: SpeculativeResult
                child_result, parent_result = await asyncio.gather(
                    self._speculative_consume_single(
                        entity_id,
                        resource,
                        consume,
                        ttl_seconds,
                        shard_id=effective_shard_id,
                    ),
                    self._speculative_consume_single(
                        parent_id_cached, resource, consume, ttl_seconds
                    ),
                )
                if child_result.success:
                    shards_cached = {**shards_cached, resource: child_result.shard_count}
                    self._entity_cache[cache_key] = (
                        child_result.cascade,
                        child_result.parent_id,
                        shards_cached,
                    )
                else:
                    # On failure, _speculative_consume_single doesn't return
                    # cascade/parent_id (only in ALL_NEW). Use cached values
                    # so the caller can compensate the parent.
                    child_result.cascade = cascade_cached
                    child_result.parent_id = parent_id_cached
                child_result.parent_result = parent_result
                return child_result

        # Cache miss or non-cascade: single UpdateItem
        result = await self._speculative_consume_single(
            entity_id, resource, consume, ttl_seconds, shard_id=effective_shard_id
        )
        if result.success:
            existing_shards = self._entity_cache.get(cache_key, (False, None, {}))[2]
            existing_shards = {**existing_shards, resource: result.shard_count}
            self._entity_cache[cache_key] = (result.cascade, result.parent_id, existing_shards)
        return result

    async def _speculative_consume_single(
        self,
        entity_id: str,
        resource: str,
        consume: dict[str, int],
        ttl_seconds: int | None = None,
        shard_id: int = 0,
    ) -> SpeculativeResult:
        """Issue a single speculative UpdateItem on a bucket shard.

        In addition to consuming the application-level limits, this method
        auto-consumes 1 WCU (1000 millitokens) from the ``wcu`` infrastructure
        limit and includes ``wcu tk >= 1000`` in the condition expression.

        Args:
            entity_id: Entity owning the bucket.
            resource: Resource name.
            consume: Amount per limit (tokens, not milli).
            ttl_seconds: TTL in seconds, or None for no TTL change.
            shard_id: Target shard index (default 0).

        Returns:
            SpeculativeResult with shard_id and shard_count populated.
        """
        client = await self._get_client()

        # Build ADD expression for each limit
        add_parts: list[str] = []
        condition_parts: list[str] = ["attribute_exists(PK)"]
        attr_names: dict[str, str] = {}
        attr_values: dict[str, Any] = {}

        for limit_name, amount in consume.items():
            amount_milli = amount * 1000
            tk_attr = schema.bucket_attr(limit_name, schema.BUCKET_FIELD_TK)
            tc_attr = schema.bucket_attr(limit_name, schema.BUCKET_FIELD_TC)
            tk_alias = f"#tk_{limit_name}"
            tc_alias = f"#tc_{limit_name}"
            neg_val = f":neg_{limit_name}"
            pos_val = f":pos_{limit_name}"
            thresh_val = f":thresh_{limit_name}"

            attr_names[tk_alias] = tk_attr
            attr_names[tc_alias] = tc_attr
            attr_values[neg_val] = {"N": str(-amount_milli)}
            attr_values[pos_val] = {"N": str(amount_milli)}
            attr_values[thresh_val] = {"N": str(amount_milli)}

            add_parts.append(f"{tk_alias} {neg_val}")
            add_parts.append(f"{tc_alias} {pos_val}")
            condition_parts.append(f"{tk_alias} >= {thresh_val}")

        # Add wcu infrastructure limit consumption (1 WCU = 1000 millitokens per write)
        wcu_tk_attr = schema.bucket_attr(schema.WCU_LIMIT_NAME, schema.BUCKET_FIELD_TK)
        wcu_tc_attr = schema.bucket_attr(schema.WCU_LIMIT_NAME, schema.BUCKET_FIELD_TC)
        attr_names["#wcu_tk"] = wcu_tk_attr
        attr_names["#wcu_tc"] = wcu_tc_attr
        wcu_milli = 1000  # 1 WCU = 1000 millitokens
        attr_values[":neg_wcu"] = {"N": str(-wcu_milli)}
        attr_values[":pos_wcu"] = {"N": str(wcu_milli)}
        attr_values[":thresh_wcu"] = {"N": str(wcu_milli)}
        add_parts.append("#wcu_tk :neg_wcu")
        add_parts.append("#wcu_tc :pos_wcu")
        condition_parts.append("#wcu_tk >= :thresh_wcu")

        update_expr = "ADD " + ", ".join(add_parts)

        # Handle TTL
        if ttl_seconds is not None:
            now_ms = self._now_ms()
            ttl_epoch = schema.calculate_ttl(now_ms, ttl_seconds)
            update_expr = f"SET #ttl = :ttl {update_expr}"
            attr_names["#ttl"] = "ttl"
            attr_values[":ttl"] = {"N": str(ttl_epoch)}

        # Reject expired-but-not-yet-deleted buckets (DynamoDB TTL is eventual)
        now_epoch = self._now_ms() // 1000
        attr_names["#ttl"] = "ttl"
        attr_values[":now_epoch"] = {"N": str(now_epoch)}
        condition_parts.append("(attribute_not_exists(#ttl) OR #ttl > :now_epoch)")

        condition_expr = " AND ".join(condition_parts)

        try:
            response = await client.update_item(
                TableName=self.table_name,
                Key={
                    "PK": {
                        "S": schema.pk_bucket(self._namespace_id, entity_id, resource, shard_id)
                    },
                    "SK": {"S": schema.sk_state()},
                },
                UpdateExpression=update_expr,
                ConditionExpression=condition_expr,
                ExpressionAttributeNames=attr_names,
                ExpressionAttributeValues=attr_values,
                ReturnValues="ALL_NEW",
                ReturnValuesOnConditionCheckFailure="ALL_OLD",
            )

            # Success: deserialize ALL_NEW
            item = response["Attributes"]
            buckets = self._deserialize_composite_bucket(item)
            cascade = item.get("cascade", {}).get("BOOL", False)
            parent_id = item.get("parent_id", {}).get("S")
            shard_count = int(item.get("shard_count", {}).get("N", "1"))
            return SpeculativeResult(
                success=True,
                buckets=buckets,
                cascade=cascade,
                parent_id=parent_id,
                shard_id=shard_id,
                shard_count=shard_count,
            )

        except ClientError as e:
            if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException":
                old_item = cast(dict[str, Any] | None, e.response.get("Item"))
                if old_item:
                    old_buckets = self._deserialize_composite_bucket(old_item)
                    old_shard_count = int(old_item.get("shard_count", {}).get("N", "1"))

                    # Classify failure reason (GHSA-76rv)
                    wcu_exhausted = any(
                        b.limit_name == schema.WCU_LIMIT_NAME and b.tokens_milli < 1000
                        for b in old_buckets
                    )
                    app_exhausted = any(
                        b.limit_name != schema.WCU_LIMIT_NAME
                        and b.tokens_milli < consume.get(b.limit_name, 0) * 1000
                        for b in old_buckets
                    )
                    if wcu_exhausted and app_exhausted:
                        reason = SpeculativeFailureReason.BOTH_EXHAUSTED
                    elif wcu_exhausted:
                        reason = SpeculativeFailureReason.WCU_EXHAUSTED
                    else:
                        reason = SpeculativeFailureReason.APP_LIMIT_EXHAUSTED

                    return SpeculativeResult(
                        success=False,
                        old_buckets=old_buckets,
                        shard_id=shard_id,
                        shard_count=old_shard_count,
                        failure_reason=reason,
                    )
                else:
                    return SpeculativeResult(
                        success=False,
                        shard_id=shard_id,
                        failure_reason=SpeculativeFailureReason.BUCKET_MISSING,
                    )
            raise

    async def bump_shard_count(self, entity_id: str, resource: str, current_count: int) -> int:
        """Double shard_count on shard 0 via conditional write.

        Shard 0 is the source of truth for shard_count. The conditional
        expression ``shard_count = :old`` prevents double-bumping when
        multiple clients race to double concurrently. Also updates the
        entity cache with the new shard_count.

        Args:
            entity_id: Entity owning the bucket.
            resource: Resource name.
            current_count: Current shard_count to double.

        Returns:
            The new shard_count (doubled), or the current value if another
            client already doubled (ConditionalCheckFailedException).
        """
        new_count = current_count * 2
        client = await self._get_client()
        try:
            await client.update_item(
                TableName=self.table_name,
                Key={
                    "PK": {"S": schema.pk_bucket(self._namespace_id, entity_id, resource, 0)},
                    "SK": {"S": schema.sk_state()},
                },
                UpdateExpression="SET shard_count = :new",
                ConditionExpression="shard_count = :old",
                ExpressionAttributeValues={
                    ":old": {"N": str(current_count)},
                    ":new": {"N": str(new_count)},
                },
            )
            effective_count = new_count
            if new_count > schema.WCU_SHARD_WARN_THRESHOLD:
                logger.warning(
                    "High shard count after doubling: entity_id=%s resource=%s "
                    "shard_count=%d threshold=%d",
                    entity_id,
                    resource,
                    new_count,
                    schema.WCU_SHARD_WARN_THRESHOLD,
                )
        except ClientError as e:
            if e.response.get("Error", {}).get("Code") == "ConditionalCheckFailedException":
                effective_count = current_count  # Another client already doubled
            else:
                raise

        # Update entity cache with new shard_count
        cache_key = (self._namespace_id, entity_id)
        entry = self._entity_cache.get(cache_key, (False, None, {}))
        shards = {**entry[2], resource: effective_count}
        self._entity_cache[cache_key] = (entry[0], entry[1], shards)
        return effective_count

    # -------------------------------------------------------------------------
    # Limit config operations
    # -------------------------------------------------------------------------

    async def set_limits(
        self,
        entity_id: str,
        limits: list[Limit],
        resource: str = schema.DEFAULT_RESOURCE,
        principal: str | None = None,
    ) -> None:
        """
        Store limit configs for an entity (composite format, ADR-114).

        All limits for an entity+resource are stored in a single composite item
        with SK '#CONFIG#{resource}'. This reduces cache-miss cost from N GetItem
        calls to 1 GetItem call.

        Args:
            entity_id: ID of the entity
            limits: List of Limit configurations to store
            resource: Resource name (defaults to "_default_")
            principal: Caller identity for audit logging
        """
        client = await self._get_client()

        # Build composite config item with all limits
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
            "SK": {"S": schema.sk_config(resource)},
            "entity_id": {"S": entity_id},
            "resource": {"S": resource},
            "config_version": {"N": "1"},
            # GSI3 attributes for sparse indexing (entity config queries)
            "GSI3PK": {"S": schema.gsi3_pk_entity_config(self._namespace_id, resource)},
            "GSI3SK": {"S": schema.gsi3_sk_entity(entity_id)},
            # GSI4: namespace-scoped item discovery
            "GSI4PK": {"S": self._namespace_id},
            "GSI4SK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
        }

        # Add l_* attributes for each limit
        self._serialize_composite_limits(limits, item)

        # Use transaction to atomically create config + increment registry (issue #288)
        # This prevents race conditions where concurrent creates both increment
        try:
            await client.transact_write_items(
                TransactItems=[
                    {
                        "Put": {
                            "TableName": self.table_name,
                            "Item": item,
                            "ConditionExpression": "attribute_not_exists(PK)",
                        }
                    },
                    {
                        "Update": {
                            "TableName": self.table_name,
                            "Key": {
                                "PK": {"S": schema.pk_system(self._namespace_id)},
                                "SK": {"S": schema.sk_entity_config_resources()},
                            },
                            "UpdateExpression": (
                                "SET GSI4PK = if_not_exists(GSI4PK, :gsi4pk),"
                                " GSI4SK = if_not_exists(GSI4SK, :gsi4sk)"
                                " ADD #resource :one"
                            ),
                            "ExpressionAttributeNames": {"#resource": resource},
                            "ExpressionAttributeValues": {
                                ":one": {"N": "1"},
                                ":gsi4pk": {"S": self._namespace_id},
                                ":gsi4sk": {"S": schema.pk_system(self._namespace_id)},
                            },
                        }
                    },
                ]
            )
        except ClientError as e:
            if e.response["Error"]["Code"] == "TransactionCanceledException":
                # Check if cancellation was due to condition failure (UPDATE case)
                reasons = e.response.get("CancellationReasons", [])
                if reasons and reasons[0].get("Code") == "ConditionalCheckFailed":
                    # Config already exists - UPDATE case, just overwrite
                    await client.put_item(TableName=self.table_name, Item=item)
                else:
                    raise
            else:
                raise

        # Sync bucket static params if bucket exists (issue #294, #327)
        # Entity config = no TTL (bucket_ttl_refill_multiplier=0 → REMOVE ttl)
        await self._sync_bucket_params(entity_id, resource, limits, bucket_ttl_refill_multiplier=0)

        # Auto-evict from config cache so next resolve reads fresh config (ADR-122)
        self._config_cache.evict_entity(entity_id, resource)

        # Log audit event
        await self._log_audit_event(
            action=AuditAction.LIMITS_SET,
            entity_id=entity_id,
            principal=principal,
            resource=resource,
            details={"limits": [limit.to_dict() for limit in limits]},
        )

    async def _sync_bucket_params(
        self,
        entity_id: str,
        resource: str,
        limits: list[Limit],
        bucket_ttl_refill_multiplier: int | None = None,
        stale_limit_names: set[str] | None = None,
    ) -> None:
        """Sync bucket static params when limits change (issue #294, #327).

        Updates capacity, refill_amount, refill_period, and TTL for
        existing buckets. Optionally removes stale limit attributes.
        Uses conditional update with attribute_exists(PK) to skip if
        bucket doesn't exist yet.

        Args:
            entity_id: ID of the entity
            resource: Resource name
            limits: New limit configurations
            bucket_ttl_refill_multiplier: TTL behavior (issue #327):
                - None: Don't change TTL
                - 0: REMOVE ttl (entity has custom limits)
                - >0: SET ttl to (now + calculated_seconds)
            stale_limit_names: Limit names to REMOVE from bucket (issue #327).
                Used when downgrading from entity config to defaults where
                the old config had limits not present in the new defaults.
        """
        if not limits:
            return

        client = await self._get_client()

        # Build SET expression for static bucket params
        # Use numeric index for expression names since limit names can contain hyphens
        set_parts: list[str] = []
        remove_parts: list[str] = []
        expr_names: dict[str, str] = {}
        expr_values: dict[str, dict[str, str]] = {}

        for i, limit in enumerate(limits):
            name = limit.name
            # Capacity (millitokens)
            cp_attr = schema.bucket_attr(name, schema.BUCKET_FIELD_CP)
            set_parts.append(f"#cp{i} = :cp{i}")
            expr_names[f"#cp{i}"] = cp_attr
            expr_values[f":cp{i}"] = {"N": str(limit.capacity * 1000)}

            # Refill amount (millitokens)
            ra_attr = schema.bucket_attr(name, schema.BUCKET_FIELD_RA)
            set_parts.append(f"#ra{i} = :ra{i}")
            expr_names[f"#ra{i}"] = ra_attr
            expr_values[f":ra{i}"] = {"N": str(limit.refill_amount * 1000)}

            # Refill period (milliseconds)
            rp_attr = schema.bucket_attr(name, schema.BUCKET_FIELD_RP)
            set_parts.append(f"#rp{i} = :rp{i}")
            expr_names[f"#rp{i}"] = rp_attr
            expr_values[f":rp{i}"] = {"N": str(limit.refill_period_seconds * 1000)}

        # Handle TTL update (issue #327)
        if bucket_ttl_refill_multiplier is not None:
            expr_names["#ttl"] = "ttl"
            if bucket_ttl_refill_multiplier > 0:
                ttl_seconds = schema.calculate_bucket_ttl_seconds(
                    limits, bucket_ttl_refill_multiplier
                )
                if ttl_seconds is not None:
                    now_ms = self._now_ms()
                    set_parts.append("#ttl = :ttl_val")
                    expr_values[":ttl_val"] = {"N": str(schema.calculate_ttl(now_ms, ttl_seconds))}
            else:
                # REMOVE ttl (entity has custom limits, bucket should persist)
                remove_parts.append("#ttl")

        # Handle stale limit attribute removal (issue #327)
        # Note: RF (last_refill_ms) is shared across all limits in a composite
        # bucket and must NOT be removed when individual limits are stale.
        if stale_limit_names:
            for stale_name in stale_limit_names:
                for field in (
                    schema.BUCKET_FIELD_TK,
                    schema.BUCKET_FIELD_CP,
                    schema.BUCKET_FIELD_RA,
                    schema.BUCKET_FIELD_RP,
                    schema.BUCKET_FIELD_TC,
                ):
                    alias = f"#stale_{stale_name}_{field}"
                    expr_names[alias] = schema.bucket_attr(stale_name, field)
                    remove_parts.append(alias)

        # Build update expression
        update_expr = f"SET {', '.join(set_parts)}"
        if remove_parts:
            update_expr += f" REMOVE {', '.join(remove_parts)}"

        try:
            await client.update_item(
                TableName=self.table_name,
                Key={
                    "PK": {"S": schema.pk_bucket(self._namespace_id, entity_id, resource, 0)},
                    "SK": {"S": schema.sk_state()},
                },
                UpdateExpression=update_expr,
                ConditionExpression="attribute_exists(PK)",
                ExpressionAttributeNames=expr_names,
                ExpressionAttributeValues=expr_values,
            )
        except ClientError as e:
            if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
                # Bucket doesn't exist yet - that's fine, it will be created
                # with the correct params on first acquire()
                return
            raise

    async def reconcile_bucket_to_defaults(
        self,
        entity_id: str,
        resource: str,
        effective_limits: list[Limit],
        stale_limit_names: set[str] | None = None,
    ) -> None:
        """Reconcile bucket to effective defaults after config deletion (issue #327).

        Updates limit fields (cp, ra, rp) to match the new effective
        limits, sets TTL (since entity is now on defaults), and removes
        stale limit attributes that no longer exist in the effective config.

        No-op if bucket doesn't exist (uses attribute_exists(PK) condition).

        Args:
            entity_id: ID of the entity
            resource: Resource name
            effective_limits: The new effective limits (resource/system defaults)
            stale_limit_names: Limit names to REMOVE from bucket (limits that
                were in the deleted entity config but not in effective defaults)
        """
        await self._sync_bucket_params(
            entity_id,
            resource,
            effective_limits,
            bucket_ttl_refill_multiplier=self._bucket_ttl_refill_multiplier,
            stale_limit_names=stale_limit_names,
        )

    async def _cleanup_entity_config_registry(self, resource: str) -> None:
        """Remove resource from entity config registry if count <= 0.

        Called after decrementing to clean up zero/negative counts.
        Uses conditional REMOVE to avoid race conditions.
        """
        client = await self._get_client()

        try:
            await client.update_item(
                TableName=self.table_name,
                Key={
                    "PK": {"S": schema.pk_system(self._namespace_id)},
                    "SK": {"S": schema.sk_entity_config_resources()},
                },
                UpdateExpression="REMOVE #resource",
                ConditionExpression="#resource <= :zero",
                ExpressionAttributeNames={"#resource": resource},
                ExpressionAttributeValues={":zero": {"N": "0"}},
            )
        except ClientError as e:
            if e.response["Error"]["Code"] != "ConditionalCheckFailedException":
                raise
            # Count > 0, which is expected - nothing to clean up

    async def get_limits(
        self,
        entity_id: str,
        resource: str = schema.DEFAULT_RESOURCE,
    ) -> list[Limit]:
        """Get stored limit configs for an entity (composite format, ADR-114).

        Reads a single composite item with SK '#CONFIG#{resource}' containing
        all limits for the entity+resource pair.
        """
        client = await self._get_client()

        # ADR-105: Use eventually consistent reads for config (0.5 RCU vs 1 RCU)
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
                "SK": {"S": schema.sk_config(resource)},
            },
            ConsistentRead=False,
        )

        item = response.get("Item")
        if not item:
            return []

        return self._deserialize_composite_limits(item)

    async def delete_limits(
        self,
        entity_id: str,
        resource: str = schema.DEFAULT_RESOURCE,
        principal: str | None = None,
    ) -> None:
        """
        Delete stored limit configs for an entity (composite format, ADR-114).

        Deletes the single composite config item for this entity+resource.

        Args:
            entity_id: ID of the entity
            resource: Resource name (defaults to "_default_")
            principal: Caller identity for audit logging
        """
        client = await self._get_client()

        # Use transaction to atomically delete config + decrement registry (issue #288)
        # This prevents double-decrement if delete_limits is called twice
        try:
            await client.transact_write_items(
                TransactItems=[
                    {
                        "Delete": {
                            "TableName": self.table_name,
                            "Key": {
                                "PK": {"S": schema.pk_entity(self._namespace_id, entity_id)},
                                "SK": {"S": schema.sk_config(resource)},
                            },
                            "ConditionExpression": "attribute_exists(PK)",
                        }
                    },
                    {
                        "Update": {
                            "TableName": self.table_name,
                            "Key": {
                                "PK": {"S": schema.pk_system(self._namespace_id)},
                                "SK": {"S": schema.sk_entity_config_resources()},
                            },
                            "UpdateExpression": "ADD #resource :minus_one",
                            "ExpressionAttributeNames": {"#resource": resource},
                            "ExpressionAttributeValues": {":minus_one": {"N": "-1"}},
                        }
                    },
                ]
            )
        except ClientError as e:
            if e.response["Error"]["Code"] == "TransactionCanceledException":
                # Check if cancellation was due to condition failure (item doesn't exist)
                reasons = e.response.get("CancellationReasons", [])
                if reasons and reasons[0].get("Code") == "ConditionalCheckFailed":
                    # Config doesn't exist - nothing to delete, skip audit
                    return
                else:
                    raise
            else:
                raise

        # Cleanup: remove registry attribute if count <= 0
        await self._cleanup_entity_config_registry(resource)

        # Auto-evict from config cache so next resolve reads fresh config (ADR-122)
        self._config_cache.evict_entity(entity_id, resource)

        # Log audit event
        await self._log_audit_event(
            action=AuditAction.LIMITS_DELETED,
            entity_id=entity_id,
            principal=principal,
            resource=resource,
        )

    async def list_entities_with_custom_limits(
        self,
        resource: str,
        limit: int | None = None,
        cursor: str | None = None,
    ) -> tuple[list[str], str | None]:
        """
        List all entities that have custom limit configurations for a resource.

        Uses GSI3 sparse index for efficient queries. Only entity-level configs
        have GSI3 attributes, so this query returns only entities with custom
        limits (not system or resource defaults).

        Args:
            resource: Resource to filter by (required).
            limit: Maximum number of entities to return. None for all.
            cursor: Pagination cursor from previous call. None for first page.

        Returns:
            Tuple of (entity_ids, next_cursor). next_cursor is None if no more results.
        """
        import base64
        import json

        client = await self._get_client()

        query_params: dict[str, Any] = {
            "TableName": self.table_name,
            "IndexName": schema.GSI3_NAME,
            "KeyConditionExpression": "GSI3PK = :pk",
            "ExpressionAttributeValues": {
                ":pk": {"S": schema.gsi3_pk_entity_config(self._namespace_id, resource)}
            },
        }

        if limit is not None:
            query_params["Limit"] = limit
        if cursor is not None:
            # Decode cursor (base64 encoded LastEvaluatedKey)
            query_params["ExclusiveStartKey"] = json.loads(base64.b64decode(cursor))

        response = await client.query(**query_params)

        entity_ids: list[str] = []
        for item in response.get("Items", []):
            entity_id = item.get("GSI3SK", {}).get("S")
            if entity_id:
                entity_ids.append(entity_id)

        # Encode next cursor if more results
        next_cursor: str | None = None
        if "LastEvaluatedKey" in response:
            next_cursor = base64.b64encode(
                json.dumps(response["LastEvaluatedKey"]).encode()
            ).decode()

        return entity_ids, next_cursor

    async def list_resources_with_entity_configs(self) -> list[str]:
        """
        List all resources that have entity-level custom limit configs.

        Uses the entity config resources registry (wide column with ref counts)
        for efficient O(1) lookup. Returns resources with count > 0.

        Returns:
            Sorted list of resource names with at least one entity having custom limits
        """
        client = await self._get_client()

        # Read from entity config resources registry (single GetItem: 1 RCU)
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_entity_config_resources()},
            },
            ConsistentRead=False,
        )

        # Extract resource names from numeric attributes (wide column pattern)
        item = response.get("Item", {})
        resources = []
        for attr_name, attr_value in item.items():
            # Skip key attributes
            if attr_name in ("PK", "SK"):
                continue
            # Include resources with count > 0
            count_str = attr_value.get("N")
            if count_str is not None and int(count_str) > 0:
                resources.append(attr_name)

        return sorted(resources)

    # -------------------------------------------------------------------------
    # Resource-level limit config operations (composite format, ADR-114)
    # -------------------------------------------------------------------------

    async def set_resource_defaults(
        self,
        resource: str,
        limits: list[Limit],
        principal: str | None = None,
    ) -> None:
        """
        Store default limit configs for a resource (composite format, ADR-114).

        All limits for a resource are stored in a single composite item
        with SK '#CONFIG'. This reduces cache-miss cost.

        Args:
            resource: Resource name
            limits: List of Limit configurations to store
            principal: Caller identity for audit logging
        """
        validate_resource(resource)
        client = await self._get_client()

        # Build composite config item with all limits
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_resource(self._namespace_id, resource)},
            "SK": {"S": schema.sk_config()},
            "resource": {"S": resource},
            "config_version": {"N": "1"},
            # GSI4: namespace-scoped item discovery
            "GSI4PK": {"S": self._namespace_id},
            "GSI4SK": {"S": schema.pk_resource(self._namespace_id, resource)},
        }

        # Add l_* attributes for each limit
        self._serialize_composite_limits(limits, item)

        # Single PutItem replaces any existing config for this resource
        await client.put_item(TableName=self.table_name, Item=item)

        # Add resource to the registry using atomic ADD operation
        await client.update_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_resources()},
            },
            UpdateExpression=(
                "SET GSI4PK = if_not_exists(GSI4PK, :gsi4pk),"
                " GSI4SK = if_not_exists(GSI4SK, :gsi4sk)"
                " ADD resources :resource"
            ),
            ExpressionAttributeValues={
                ":resource": {"SS": [resource]},
                ":gsi4pk": {"S": self._namespace_id},
                ":gsi4sk": {"S": schema.pk_system(self._namespace_id)},
            },
        )

        # Log audit event with special prefix
        await self._log_audit_event(
            action=AuditAction.LIMITS_SET,
            entity_id=f"$RESOURCE:{resource}",
            principal=principal,
            resource=resource,
            details={"limits": [limit.to_dict() for limit in limits]},
        )

    async def get_resource_defaults(
        self,
        resource: str,
    ) -> list[Limit]:
        """Get stored default limit configs for a resource (composite format, ADR-114).

        Reads a single composite item with SK '#CONFIG' containing all limits
        for the resource.
        """
        validate_resource(resource)
        client = await self._get_client()

        # ADR-105: Use eventually consistent reads for config (0.5 RCU vs 1 RCU)
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_resource(self._namespace_id, resource)},
                "SK": {"S": schema.sk_config()},
            },
            ConsistentRead=False,
        )

        item = response.get("Item")
        if not item:
            return []

        return self._deserialize_composite_limits(item)

    async def delete_resource_defaults(
        self,
        resource: str,
        principal: str | None = None,
    ) -> None:
        """
        Delete stored default limit configs for a resource (composite format, ADR-114).

        Deletes the single composite config item for this resource.

        Args:
            resource: Resource name
            principal: Caller identity for audit logging
        """
        validate_resource(resource)
        client = await self._get_client()

        # Single DeleteItem removes the composite config
        await client.delete_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_resource(self._namespace_id, resource)},
                "SK": {"S": schema.sk_config()},
            },
        )

        # Remove resource from the registry using atomic DELETE operation
        await client.update_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_resources()},
            },
            UpdateExpression="DELETE resources :resource",
            ExpressionAttributeValues={
                ":resource": {"SS": [resource]},
            },
        )

        # Log audit event
        await self._log_audit_event(
            action=AuditAction.LIMITS_DELETED,
            entity_id=f"$RESOURCE:{resource}",
            principal=principal,
            resource=resource,
        )

    async def list_resources_with_defaults(self) -> list[str]:
        """List all resources that have default limit configs from the resource registry."""
        client = await self._get_client()

        # Read from resource registry (single GetItem: 1 RCU)
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_resources()},
            },
            ConsistentRead=False,
        )

        # Extract resources from the string set, or return empty list if registry doesn't exist
        item = response.get("Item", {})
        resources_set = item.get("resources", {}).get("SS", [])
        return sorted(resources_set)

    # -------------------------------------------------------------------------
    # System-level default config operations (composite format, ADR-114)
    # -------------------------------------------------------------------------

    async def set_system_defaults(
        self,
        limits: list[Limit],
        on_unavailable: OnUnavailableAction | None = None,
        principal: str | None = None,
    ) -> None:
        """
        Store system-wide default limits and config (composite format, ADR-114).

        All system limits and config (on_unavailable) are stored in a single
        composite item with SK '#CONFIG'. This reduces cache-miss cost.

        Args:
            limits: List of Limit configurations (apply to all resources)
            on_unavailable: Behavior when DynamoDB unavailable ("allow" or "block")
            principal: Caller identity for audit logging
        """
        client = await self._get_client()

        # Build composite config item with all limits + on_unavailable
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_system(self._namespace_id)},
            "SK": {"S": schema.sk_config()},
            "config_version": {"N": "1"},
        }

        # Add on_unavailable if provided
        if on_unavailable is not None:
            item["on_unavailable"] = {"S": on_unavailable}

        # Add l_* attributes for each limit
        self._serialize_composite_limits(limits, item)

        # GSI4: namespace-scoped item discovery
        item["GSI4PK"] = {"S": self._namespace_id}
        item["GSI4SK"] = {"S": schema.pk_system(self._namespace_id)}

        # Single PutItem replaces any existing system config
        await client.put_item(TableName=self.table_name, Item=item)

        # Log audit event (ADR-106: use $SYSTEM for all system-level events)
        await self._log_audit_event(
            action=AuditAction.LIMITS_SET,
            entity_id="$SYSTEM",
            principal=principal,
            details={
                "limits": [limit.to_dict() for limit in limits],
                "on_unavailable": on_unavailable,
            },
        )

    async def get_system_defaults(self) -> tuple[list[Limit], OnUnavailableAction | None]:
        """
        Get system-wide default limits and config (composite format, ADR-114).

        Reads a single composite item with SK '#CONFIG' containing all limits
        and on_unavailable setting.

        Returns:
            Tuple of (limits, on_unavailable). on_unavailable may be None if not set.
        """
        client = await self._get_client()

        # ADR-105: Use eventually consistent reads for config (0.5 RCU vs 1 RCU)
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_config()},
            },
            ConsistentRead=False,
        )

        item = response.get("Item")
        if not item:
            return [], None

        # Extract limits from composite attributes
        limits = self._deserialize_composite_limits(item)

        # Extract on_unavailable
        on_unavailable_attr = item.get("on_unavailable", {})
        on_unavailable: OnUnavailableAction | None = (
            cast(OnUnavailableAction, on_unavailable_attr.get("S"))
            if on_unavailable_attr and on_unavailable_attr.get("S")
            else None
        )

        return limits, on_unavailable

    async def delete_system_defaults(
        self,
        principal: str | None = None,
    ) -> None:
        """
        Delete all system-wide default limits and config (composite format, ADR-114).

        Deletes the single composite config item for system defaults.

        Args:
            principal: Caller identity for audit logging
        """
        client = await self._get_client()

        # Get existing limits for audit logging before deleting
        limits, on_unavailable = await self.get_system_defaults()

        # Single DeleteItem removes the composite config
        await client.delete_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_config()},
            },
        )

        # Log audit event (ADR-106: use $SYSTEM for all system-level events)
        await self._log_audit_event(
            action=AuditAction.LIMITS_DELETED,
            entity_id="$SYSTEM",
            principal=principal,
            details={
                "limits": [limit.name for limit in limits],
                "on_unavailable": on_unavailable,
            },
        )

    async def get_system_limits(self) -> list[Limit]:
        """Get system-wide default limits (without config).

        This is a convenience method that returns only the limits.
        Use get_system_defaults() to also get on_unavailable config.
        """
        limits, _ = await self.get_system_defaults()
        return limits

    # -------------------------------------------------------------------------
    # Provisioner state (declarative limits management, Issue #405)
    # -------------------------------------------------------------------------

    async def get_provisioner_state(self) -> dict[str, Any]:
        """Get the provisioner state record for this namespace.

        Returns:
            Dict with keys: managed_system, managed_resources, managed_entities,
            last_applied, applied_hash. Returns empty state if no record exists.
        """
        client = await self._get_client()
        result = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_provisioner()},
            },
        )
        item = result.get("Item")
        if not item:
            return {
                "managed_system": False,
                "managed_resources": [],
                "managed_entities": {},
                "last_applied": None,
                "applied_hash": None,
            }

        managed_entities: dict[str, list[str]] = {}
        raw_entities = item.get("managed_entities", {}).get("M", {})
        for entity_id, resources_attr in raw_entities.items():
            managed_entities[entity_id] = [r["S"] for r in resources_attr.get("L", [])]

        return {
            "managed_system": item.get("managed_system", {}).get("BOOL", False),
            "managed_resources": [r["S"] for r in item.get("managed_resources", {}).get("L", [])],
            "managed_entities": managed_entities,
            "last_applied": item.get("last_applied", {}).get("S"),
            "applied_hash": item.get("applied_hash", {}).get("S"),
        }

    async def put_provisioner_state(self, state: dict[str, Any]) -> None:
        """Write the provisioner state record for this namespace.

        Args:
            state: Dict with keys: managed_system, managed_resources,
                   managed_entities, last_applied, applied_hash.
        """
        client = await self._get_client()
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_system(self._namespace_id)},
            "SK": {"S": schema.sk_provisioner()},
            "GSI4PK": {"S": self._namespace_id},
            "managed_system": {"BOOL": state["managed_system"]},
            "managed_resources": {"L": [{"S": r} for r in state["managed_resources"]]},
            "managed_entities": {
                "M": {
                    entity_id: {"L": [{"S": r} for r in resources]}
                    for entity_id, resources in state["managed_entities"].items()
                }
            },
            "last_applied": {"S": state["last_applied"]},
            "applied_hash": {"S": state["applied_hash"]},
        }
        await client.put_item(TableName=self.table_name, Item=item)

    # -------------------------------------------------------------------------
    # Audit retention configuration
    # -------------------------------------------------------------------------

    async def _write_audit_retention_config(self) -> None:
        """
        Write audit_retention_days to system config item.

        Uses atomic UpdateItem to avoid overwriting other system config fields.
        Called from ensure_infrastructure() after stack creation.
        """
        if self._stack_options is None:
            return
        client = await self._get_client()
        await client.update_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_config()},
            },
            UpdateExpression="SET audit_retention_days = :ard",
            ExpressionAttributeValues={
                ":ard": {"N": str(self._stack_options.audit_retention_days)},
            },
        )
        # Update cache
        self._audit_retention_days_cache = self._stack_options.audit_retention_days

    async def _get_audit_retention_days(self) -> int:
        """
        Get audit retention days from system config or default.

        Returns cached value if available, otherwise reads from DynamoDB.
        Falls back to stack_options if available (saves DynamoDB call).
        Default is 90 days if not configured anywhere.
        """
        if self._audit_retention_days_cache is not None:
            return self._audit_retention_days_cache

        # Try to read from stack_options first (saves DynamoDB call)
        if self._stack_options is not None:
            self._audit_retention_days_cache = self._stack_options.audit_retention_days
            return self._audit_retention_days_cache

        # Read from DynamoDB system config
        client = await self._get_client()
        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(self._namespace_id)},
                "SK": {"S": schema.sk_config()},
            },
            ConsistentRead=False,
        )

        item = response.get("Item", {})
        ard = item.get("audit_retention_days", {}).get("N")
        self._audit_retention_days_cache = int(ard) if ard else 90  # Default 90 days
        return self._audit_retention_days_cache

    # -------------------------------------------------------------------------
    # Version record operations
    # -------------------------------------------------------------------------

    async def get_version_record(self) -> dict[str, Any] | None:
        """
        Get the infrastructure version record.

        Returns:
            Version record with schema_version, lambda_version, etc.
            None if no version record exists.
        """
        client = await self._get_client()

        response = await client.get_item(
            TableName=self.table_name,
            Key={
                "PK": {"S": schema.pk_system(schema.RESERVED_NAMESPACE)},
                "SK": {"S": schema.sk_version()},
            },
        )

        item = response.get("Item")
        if not item:
            return None

        result: dict[str, Any] = {}
        for key in (
            "schema_version",
            "lambda_version",
            "client_min_version",
            "updated_at",
            "updated_by",
        ):
            if key in item:
                result[key] = self._deserialize_value(item[key])
        return result

    async def ping(self) -> bool:
        """
        Check if the DynamoDB table is reachable.

        Performs a lightweight GetItem operation to verify connectivity.
        Does not verify the table is initialized or has valid data.

        Returns:
            True if the table is reachable, False otherwise.
        """
        try:
            client = await self._get_client()
            await client.get_item(
                TableName=self.table_name,
                Key={
                    "PK": {"S": schema.pk_system(schema.RESERVED_NAMESPACE)},
                    "SK": {"S": schema.sk_version()},
                },
            )
            return True
        except Exception:
            return False

    async def set_version_record(
        self,
        schema_version: str,
        lambda_version: str | None = None,
        client_min_version: str = "0.0.0",
        updated_by: str | None = None,
    ) -> None:
        """
        Set the infrastructure version record.

        Args:
            schema_version: Current schema version (e.g., "1.0.0")
            lambda_version: Currently deployed Lambda version
            client_min_version: Minimum compatible client version
            updated_by: Identifier of what performed the update
        """
        client = await self._get_client()
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())

        # Flat schema (v0.6.0+)
        # Version record is table-level (one per table), uses RESERVED_NAMESPACE
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_system(schema.RESERVED_NAMESPACE)},
            "SK": {"S": schema.sk_version()},
            "schema_version": {"S": schema_version},
            "client_min_version": {"S": client_min_version},
            "updated_at": {"S": now},
            "lambda_version": {"S": lambda_version} if lambda_version else {"NULL": True},
            "updated_by": {"S": updated_by} if updated_by else {"NULL": True},
            # GSI4: table-level item discovery via RESERVED_NAMESPACE
            "GSI4PK": {"S": schema.RESERVED_NAMESPACE},
            "GSI4SK": {"S": schema.pk_system(schema.RESERVED_NAMESPACE)},
        }

        await client.put_item(TableName=self.table_name, Item=item)

    # -------------------------------------------------------------------------
    # Audit logging operations
    # -------------------------------------------------------------------------

    def _generate_event_id(self) -> str:
        """Generate a unique event ID using ULID (monotonic, collision-free)."""
        return str(ULID())

    async def _log_audit_event(
        self,
        action: str,
        entity_id: str,
        principal: str | None = None,
        resource: str | None = None,
        details: dict[str, Any] | None = None,
    ) -> AuditEvent:
        """
        Log an audit event to DynamoDB.

        Args:
            action: Type of action (see AuditAction constants)
            entity_id: ID of the entity affected
            principal: Caller identity who performed the action. If None,
                auto-detects from AWS STS caller identity (lazy cached).
            resource: Resource name for limit-related actions
            details: Additional action-specific details

        Returns:
            The created AuditEvent

        Raises:
            InvalidIdentifierError: If principal is invalid
        """
        # Auto-detect principal from AWS caller identity if not provided
        if principal is None:
            principal = await self._get_caller_identity_arn()

        # Validate principal if provided (skip validation for ARNs from STS)
        # ARNs contain colons which would fail identifier validation
        # Only validate user-provided principals that aren't ARNs
        if principal is not None and not principal.startswith("arn:"):
            validate_identifier(principal, "principal")

        # Get TTL from system config (cached)
        audit_retention_days = await self._get_audit_retention_days()
        ttl_seconds = audit_retention_days * 86400

        client = await self._get_client()
        now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
        event_id = self._generate_event_id()

        event = AuditEvent(
            event_id=event_id,
            timestamp=now,
            action=action,
            entity_id=entity_id,
            principal=principal,
            resource=resource,
            details=details or {},
        )

        # Build DynamoDB item (flat schema v0.6.0+)
        item: dict[str, Any] = {
            "PK": {"S": schema.pk_audit(self._namespace_id, entity_id)},
            "SK": {"S": schema.sk_audit(event_id)},
            "entity_id": {"S": entity_id},
            "event_id": {"S": event_id},
            "timestamp": {"S": now},
            "action": {"S": action},
            "principal": {"S": principal} if principal else {"NULL": True},
            "resource": {"S": resource} if resource else {"NULL": True},
            "details": {"M": self._serialize_map(details or {})},
            "ttl": {"N": str(schema.calculate_ttl(self._now_ms(), ttl_seconds))},
            # GSI4: namespace-scoped item discovery
            "GSI4PK": {"S": self._namespace_id},
            "GSI4SK": {"S": schema.pk_audit(self._namespace_id, entity_id)},
        }

        await client.put_item(TableName=self.table_name, Item=item)
        return event

    async def get_audit_events(
        self,
        entity_id: str,
        limit: int = 100,
        start_event_id: str | None = None,
    ) -> list[AuditEvent]:
        """
        Get audit events for an entity.

        Args:
            entity_id: ID of the entity to query
            limit: Maximum number of events to return
            start_event_id: Event ID to start after (for pagination)

        Returns:
            List of AuditEvent objects, ordered by most recent first
        """
        client = await self._get_client()

        query_args: dict[str, Any] = {
            "TableName": self.table_name,
            "KeyConditionExpression": "PK = :pk AND begins_with(SK, :sk_prefix)",
            "ExpressionAttributeValues": {
                ":pk": {"S": schema.pk_audit(self._namespace_id, entity_id)},
                ":sk_prefix": {"S": schema.SK_AUDIT},
            },
            "ScanIndexForward": False,  # Most recent first
            "Limit": limit,
        }

        if start_event_id:
            query_args["ExclusiveStartKey"] = {
                "PK": {"S": schema.pk_audit(self._namespace_id, entity_id)},
                "SK": {"S": schema.sk_audit(start_event_id)},
            }

        response = await client.query(**query_args)

        events = []
        for item in response.get("Items", []):
            event = self._deserialize_audit_event(item)
            if event:
                events.append(event)

        return events

    def _deserialize_audit_event(self, item: dict[str, Any]) -> AuditEvent | None:
        """Deserialize a DynamoDB item to AuditEvent (flat format only)."""
        if "action" not in item or "S" not in item.get("action", {}):
            return None

        details_raw = item.get("details", {})
        details = self._deserialize_map(details_raw.get("M", {})) if "M" in details_raw else {}
        principal = self._deserialize_value(item["principal"]) if "principal" in item else None
        resource = self._deserialize_value(item["resource"]) if "resource" in item else None
        return AuditEvent(
            event_id=item.get("event_id", {}).get("S", ""),
            timestamp=item.get("timestamp", {}).get("S", ""),
            action=item["action"]["S"],
            entity_id=item.get("entity_id", {}).get("S", ""),
            principal=principal,
            resource=resource,
            details=details,
        )

    # -------------------------------------------------------------------------
    # Usage snapshot operations
    # -------------------------------------------------------------------------

    async def get_usage_snapshots(
        self,
        entity_id: str | None = None,
        resource: str | None = None,
        window_type: str | None = None,
        start_time: str | None = None,
        end_time: str | None = None,
        limit: int = 100,
        next_key: dict[str, Any] | None = None,
    ) -> tuple[list[UsageSnapshot], dict[str, Any] | None]:
        """
        Query usage snapshots with filtering and pagination.

        Supports two query modes:
        1. Entity-scoped: Query by entity_id (uses primary key)
        2. Resource-scoped: Query by resource across all entities (uses GSI2)

        Args:
            entity_id: Entity to query (mutually exclusive for efficient queries)
            resource: Resource name filter (required if entity_id is None)
            window_type: Filter by window type ("hourly", "daily")
            start_time: Filter snapshots >= this timestamp (ISO format)
            end_time: Filter snapshots <= this timestamp (ISO format)
            limit: Maximum items to fetch from DynamoDB per page (default: 100)
            next_key: Pagination cursor from previous call

        Returns:
            Tuple of (snapshots, next_key). next_key is None if no more results.

        Raises:
            ValueError: If neither entity_id nor resource is provided

        Note:
            The ``limit`` parameter controls the DynamoDB query batch size.
            Client-side filters (window_type, start_time, end_time) are applied
            after fetching, so the returned count may be less than ``limit``.
            Use ``next_key`` to paginate through all matching results.
        """
        if entity_id is None and resource is None:
            raise ValueError("Either entity_id or resource must be provided")

        client = await self._get_client()
        snapshots: list[UsageSnapshot] = []

        if entity_id is not None:
            # Query by entity (primary key)
            key_condition = "PK = :pk AND begins_with(SK, :sk_prefix)"
            expression_values: dict[str, Any] = {
                ":pk": {"S": schema.pk_entity(self._namespace_id, entity_id)},
                ":sk_prefix": {"S": schema.SK_USAGE},
            }

            # If resource is also provided, narrow the SK prefix
            if resource:
                expression_values[":sk_prefix"] = {"S": f"{schema.SK_USAGE}{resource}#"}

            query_args: dict[str, Any] = {
                "TableName": self.table_name,
                "KeyConditionExpression": key_condition,
                "ExpressionAttributeValues": expression_values,
                "ScanIndexForward": False,  # Most recent first
                "Limit": limit,
            }

            if next_key:
                query_args["ExclusiveStartKey"] = next_key

            response = await client.query(**query_args)

        elif resource is not None:
            # Query by resource across entities (GSI2)
            key_condition = "GSI2PK = :pk AND begins_with(GSI2SK, :sk_prefix)"
            expression_values = {
                ":pk": {"S": schema.gsi2_pk_resource(self._namespace_id, resource)},
                ":sk_prefix": {"S": "USAGE#"},
            }

            query_args = {
                "TableName": self.table_name,
                "IndexName": schema.GSI2_NAME,
                "KeyConditionExpression": key_condition,
                "ExpressionAttributeValues": expression_values,
                "ScanIndexForward": False,  # Most recent first
                "Limit": limit,
            }

            if next_key:
                query_args["ExclusiveStartKey"] = next_key

            response = await client.query(**query_args)

        else:
            raise ValueError("Either entity_id or resource must be provided")

        # Deserialize and filter results
        for item in response.get("Items", []):
            snapshot = self._deserialize_usage_snapshot(item)
            if snapshot is None:
                continue

            # Apply filters
            if window_type and snapshot.window_type != window_type:
                continue
            if start_time and snapshot.window_start < start_time:
                continue
            if end_time and snapshot.window_start > end_time:
                continue

            snapshots.append(snapshot)

        # Get next pagination key
        returned_next_key = response.get("LastEvaluatedKey")

        return snapshots, returned_next_key

    async def get_usage_summary(
        self,
        entity_id: str | None = None,
        resource: str | None = None,
        window_type: str | None = None,
        start_time: str | None = None,
        end_time: str | None = None,
    ) -> UsageSummary:
        """
        Aggregate usage across snapshots into a summary.

        Fetches all matching snapshots (auto-paginates internally) and computes:
        - Total consumption per limit type
        - Average consumption per snapshot per limit type
        - Time range of aggregated data

        Args:
            entity_id: Entity to query
            resource: Resource name filter
            window_type: Filter by window type ("hourly", "daily")
            start_time: Filter snapshots >= this timestamp (ISO format)
            end_time: Filter snapshots <= this timestamp (ISO format)

        Returns:
            UsageSummary with aggregated statistics

        Raises:
            ValueError: If neither entity_id nor resource is provided
        """
        # Collect all matching snapshots with auto-pagination
        all_snapshots: list[UsageSnapshot] = []
        next_key: dict[str, Any] | None = None

        while True:
            snapshots, next_key = await self.get_usage_snapshots(
                entity_id=entity_id,
                resource=resource,
                window_type=window_type,
                start_time=start_time,
                end_time=end_time,
                limit=1000,  # Larger batch for efficiency
                next_key=next_key,
            )
            all_snapshots.extend(snapshots)

            if next_key is None:
                break

            # Safety limit to prevent unbounded memory usage
            if len(all_snapshots) >= 10000:
                break

        # Aggregate statistics
        total: dict[str, int] = {}
        counts: dict[str, int] = {}
        min_window: str | None = None
        max_window: str | None = None

        for snapshot in all_snapshots:
            # Track time range
            if min_window is None or snapshot.window_start < min_window:
                min_window = snapshot.window_start
            if max_window is None or snapshot.window_start > max_window:
                max_window = snapshot.window_start

            # Sum counters
            for limit_name, value in snapshot.counters.items():
                total[limit_name] = total.get(limit_name, 0) + value
                counts[limit_name] = counts.get(limit_name, 0) + 1

        # Calculate averages
        average: dict[str, float] = {}
        for limit_name, sum_value in total.items():
            count = counts.get(limit_name, 1)
            average[limit_name] = sum_value / count if count > 0 else 0.0

        return UsageSummary(
            snapshot_count=len(all_snapshots),
            total=total,
            average=average,
            min_window_start=min_window,
            max_window_start=max_window,
        )

    def _deserialize_usage_snapshot(self, item: dict[str, Any]) -> UsageSnapshot | None:
        """
        Deserialize a DynamoDB item to UsageSnapshot.

        Snapshots use FLAT schema (no nested data.M) to support atomic ADD
        operations. See issue #168.
        """
        # Extract from flat schema (not nested data.M)
        entity_id = item.get("entity_id", {}).get("S", "")
        resource = item.get("resource", {}).get("S", "")
        window_type = item.get("window", {}).get("S", "")
        window_start = item.get("window_start", {}).get("S", "")

        if not entity_id or not resource or not window_start:
            return None

        # Calculate window_end based on window_type
        window_end = self._calculate_window_end(window_start, window_type)

        # Extract total_events
        total_events = int(item.get("total_events", {}).get("N", "0"))

        # Extract counters (dynamic limit names stored as top-level attributes)
        # Known non-counter fields to exclude
        excluded_keys = {
            "PK",
            "SK",
            "entity_id",
            "resource",
            "window",
            "window_start",
            "total_events",
            "GSI2PK",
            "GSI2SK",
            "ttl",
        }

        counters: dict[str, int] = {}
        for key, value in item.items():
            if key in excluded_keys:
                continue
            # Counter values are stored as numbers
            if "N" in value:
                counters[key] = int(value["N"])

        return UsageSnapshot(
            entity_id=entity_id,
            resource=resource,
            window_start=window_start,
            window_end=window_end,
            window_type=window_type,
            counters=counters,
            total_events=total_events,
        )

    def _calculate_window_end(self, window_start: str, window_type: str) -> str:
        """Calculate window end timestamp from start and type."""
        from datetime import datetime, timedelta

        try:
            # Parse ISO timestamp
            dt = datetime.fromisoformat(window_start.replace("Z", "+00:00"))

            if window_type == "hourly":
                end_dt = dt.replace(minute=59, second=59, microsecond=999999)
            elif window_type == "daily":
                end_dt = dt.replace(hour=23, minute=59, second=59, microsecond=999999)
            elif window_type == "monthly":
                # Last day of month
                if dt.month == 12:
                    end_dt = dt.replace(year=dt.year + 1, month=1, day=1) - timedelta(seconds=1)
                else:
                    end_dt = dt.replace(month=dt.month + 1, day=1) - timedelta(seconds=1)
            else:
                # Unknown window type - return window_start as window_end
                end_dt = dt

            return end_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
        except (ValueError, AttributeError):
            return window_start

    # -------------------------------------------------------------------------
    # Resource aggregation
    # -------------------------------------------------------------------------

    async def get_resource_buckets(
        self,
        resource: str,
        limit_name: str | None = None,
    ) -> list[BucketState]:
        """Get all buckets for a resource across all entities.

        With composite items, each GSI2 entry is one composite item per
        entity. Returns individual BucketStates, optionally filtered by limit_name.
        """
        client = await self._get_client()

        key_condition = "GSI2PK = :pk AND begins_with(GSI2SK, :sk_prefix)"
        expression_values: dict[str, Any] = {
            ":pk": {"S": schema.gsi2_pk_resource(self._namespace_id, resource)},
            ":sk_prefix": {"S": "BUCKET#"},
        }

        response = await client.query(
            TableName=self.table_name,
            IndexName=schema.GSI2_NAME,
            KeyConditionExpression=key_condition,
            ExpressionAttributeValues=expression_values,
        )

        buckets: list[BucketState] = []
        for item in response.get("Items", []):
            for bucket in self._deserialize_composite_bucket(item):
                if limit_name is None or bucket.limit_name == limit_name:
                    buckets.append(bucket)

        return buckets

    # -------------------------------------------------------------------------
    # Serialization helpers
    # -------------------------------------------------------------------------

    def _serialize_map(self, data: dict[str, Any]) -> dict[str, Any]:
        """Serialize a Python dict to DynamoDB map format."""
        result: dict[str, Any] = {}
        for key, value in data.items():
            if isinstance(value, str):
                result[key] = {"S": value}
            elif isinstance(value, bool):
                result[key] = {"BOOL": value}
            elif isinstance(value, int | float):
                result[key] = {"N": str(value)}
            elif isinstance(value, dict):
                result[key] = {"M": self._serialize_map(value)}
            elif isinstance(value, list):
                result[key] = {"L": [self._serialize_value(v) for v in value]}
            elif value is None:
                result[key] = {"NULL": True}
        return result

    def _serialize_value(self, value: Any) -> dict[str, Any]:
        """Serialize a single value to DynamoDB format."""
        if isinstance(value, str):
            return {"S": value}
        elif isinstance(value, bool):
            return {"BOOL": value}
        elif isinstance(value, int | float):
            return {"N": str(value)}
        elif isinstance(value, dict):
            return {"M": self._serialize_map(value)}
        elif isinstance(value, list):
            return {"L": [self._serialize_value(v) for v in value]}
        elif value is None:
            return {"NULL": True}
        return {"S": str(value)}

    def _deserialize_map(self, data: dict[str, Any]) -> dict[str, Any]:
        """Deserialize a DynamoDB map to Python dict."""
        result = {}
        for key, value in data.items():
            result[key] = self._deserialize_value(value)
        return result

    def _deserialize_value(self, value: dict[str, Any]) -> Any:
        """Deserialize a single DynamoDB value."""
        if "S" in value:
            return value["S"]
        elif "N" in value:
            num_str = value["N"]
            return int(num_str) if "." not in num_str else float(num_str)
        elif "BOOL" in value:
            return value["BOOL"]
        elif "M" in value:
            return self._deserialize_map(value["M"])
        elif "L" in value:
            return [self._deserialize_value(v) for v in value["L"]]
        elif "NULL" in value:
            return None
        return None

    def _deserialize_entity(self, item: dict[str, Any]) -> Entity:
        """Deserialize a DynamoDB item to Entity (flat format only)."""
        entity_id = item.get("entity_id", {}).get("S", "")
        name_val = item["name"].get("S") if "name" in item else None
        parent_val = self._deserialize_value(item["parent_id"]) if "parent_id" in item else None
        cascade_val = item.get("cascade", {}).get("BOOL", False)
        metadata_val = (
            self._deserialize_map(item["metadata"].get("M", {}))
            if "metadata" in item and "M" in item.get("metadata", {})
            else {}
        )
        created_val = item.get("created_at", {}).get("S")

        return Entity(
            id=entity_id,
            name=name_val,
            parent_id=parent_val,
            cascade=cascade_val,
            metadata=metadata_val,
            created_at=created_val,
        )

    def _deserialize_bucket(self, item: dict[str, Any]) -> BucketState:
        """Deserialize a DynamoDB item to BucketState (flat format only)."""
        # Counter is stored as FLAT top-level attribute.
        # None if not present (old bucket without counter). See issue #179.
        counter_attr = item.get("total_consumed_milli", {})
        total_consumed_milli = int(counter_attr["N"]) if "N" in counter_attr else None

        return BucketState(
            entity_id=item.get("entity_id", {}).get("S", ""),
            resource=item.get("resource", {}).get("S", ""),
            limit_name=item.get("limit_name", {}).get("S", ""),
            tokens_milli=int(item["tokens_milli"]["N"]),
            last_refill_ms=int(item.get("last_refill_ms", {}).get("N", "0")),
            capacity_milli=int(item.get("capacity_milli", {}).get("N", "0")),
            refill_amount_milli=int(item.get("refill_amount_milli", {}).get("N", "0")),
            refill_period_ms=int(item.get("refill_period_ms", {}).get("N", "0")),
            total_consumed_milli=total_consumed_milli,
        )

    def _deserialize_composite_bucket(self, item: dict[str, Any]) -> list[BucketState]:
        """Deserialize a composite DynamoDB item to a list of BucketStates.

        A composite bucket item stores all limits for an entity+resource in a
        single DynamoDB item. Per-limit attributes use the prefix b_{name}_{field}
        with a shared rf (refill timestamp). See ADR-114.
        """
        entity_id = item.get("entity_id", {}).get("S", "")
        resource = item.get("resource", {}).get("S", "")
        rf = int(item.get(schema.BUCKET_FIELD_RF, {}).get("N", "0"))

        # Discover limit names by scanning for b_{name}_tk attributes
        limit_names: list[str] = []
        suffix = f"_{schema.BUCKET_FIELD_TK}"
        for attr_name in item:
            if attr_name.startswith(schema.BUCKET_ATTR_PREFIX) and attr_name.endswith(suffix):
                name = attr_name[len(schema.BUCKET_ATTR_PREFIX) : -len(suffix)]
                if name:
                    limit_names.append(name)

        buckets: list[BucketState] = []
        for name in limit_names:

            def _get(field: str) -> int:
                attr = schema.bucket_attr(name, field)
                return int(item.get(attr, {}).get("N", "0"))

            tc_attr = item.get(schema.bucket_attr(name, schema.BUCKET_FIELD_TC), {})
            total_consumed = int(tc_attr["N"]) if "N" in tc_attr else None

            buckets.append(
                BucketState(
                    entity_id=entity_id,
                    resource=resource,
                    limit_name=name,
                    tokens_milli=_get(schema.BUCKET_FIELD_TK),
                    last_refill_ms=rf,
                    capacity_milli=_get(schema.BUCKET_FIELD_CP),
                    refill_amount_milli=_get(schema.BUCKET_FIELD_RA),
                    refill_period_ms=_get(schema.BUCKET_FIELD_RP),
                    total_consumed_milli=total_consumed,
                )
            )

        return buckets

    # -------------------------------------------------------------------------
    # Composite limit config serialization (ADR-114 for configs)
    # -------------------------------------------------------------------------

    def _serialize_composite_limits(
        self,
        limits: list[Limit],
        base_item: dict[str, Any],
    ) -> dict[str, Any]:
        """Add l_* attributes to a DynamoDB item for composite limit storage.

        Args:
            limits: List of Limit objects to serialize
            base_item: Base DynamoDB item to add attributes to (mutated in place)

        Returns:
            The modified base_item with l_{name}_{field} attributes added
        """
        for limit in limits:
            name = limit.name
            base_item[schema.limit_attr(name, schema.LIMIT_FIELD_CP)] = {"N": str(limit.capacity)}
            base_item[schema.limit_attr(name, schema.LIMIT_FIELD_RA)] = {
                "N": str(limit.refill_amount)
            }
            base_item[schema.limit_attr(name, schema.LIMIT_FIELD_RP)] = {
                "N": str(limit.refill_period_seconds)
            }
        return base_item

    def _deserialize_composite_limits(self, item: dict[str, Any]) -> list[Limit]:
        """Deserialize l_* attributes from a DynamoDB item to Limit objects.

        Discovers limit names by scanning for l_{name}_cp attributes.

        Args:
            item: DynamoDB item with l_{name}_{field} attributes

        Returns:
            List of Limit objects reconstructed from composite attributes
        """
        # Discover limit names by scanning for l_{name}_cp attributes
        limit_names: list[str] = []
        suffix = f"_{schema.LIMIT_FIELD_CP}"
        for attr_name in item:
            if attr_name.startswith(schema.LIMIT_ATTR_PREFIX) and attr_name.endswith(suffix):
                name = attr_name[len(schema.LIMIT_ATTR_PREFIX) : -len(suffix)]
                if name:
                    limit_names.append(name)

        limits: list[Limit] = []
        for name in limit_names:

            def _get(field: str) -> int:
                attr = schema.limit_attr(name, field)
                return int(item.get(attr, {}).get("N", "0"))

            limits.append(
                Limit(
                    name=name,
                    capacity=_get(schema.LIMIT_FIELD_CP),
                    refill_amount=_get(schema.LIMIT_FIELD_RA),
                    refill_period_seconds=_get(schema.LIMIT_FIELD_RP),
                )
            )

        return limits

    # -------------------------------------------------------------------------
    # Config resolution (ADR-122)
    # -------------------------------------------------------------------------

    async def resolve_limits(
        self,
        entity_id: str,
        resource: str,
    ) -> tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]:
        """Resolve effective limits using the four-level config hierarchy.

        Uses ConfigCache with batched fetch optimization. Falls back to
        sequential individual GetItem calls if batch resolution fails.

        Args:
            entity_id: Entity to resolve limits for
            resource: Resource being accessed

        Returns:
            Tuple of (limits, on_unavailable, config_source)
        """
        # Try batched resolution (1 BatchGetItem instead of up to 4 GetItem calls)
        if self.capabilities.supports_batch_operations:
            try:
                return await self._config_cache.resolve_limits(
                    entity_id,
                    resource,
                    self.batch_get_configs,
                )
            except Exception:
                logger.debug("Batched config resolution failed, falling back to sequential")

        # Sequential fallback (or non-batch backend)
        return await self._resolve_limits_sequential(entity_id, resource)

    async def _resolve_limits_sequential(
        self,
        entity_id: str,
        resource: str,
    ) -> tuple[list[Limit] | None, OnUnavailableAction | None, ConfigSource | None]:
        """Sequential fallback for resolve_limits().

        Queries each config level individually with caching.
        """
        # Entity-level config for specific resource
        entity_limits = await self._config_cache.get_entity_limits(
            entity_id,
            resource,
            self.get_limits,
        )
        if entity_limits:
            return entity_limits, None, "entity"

        # Entity-level _default_ config
        if resource != schema.DEFAULT_RESOURCE:
            entity_default_limits = await self._config_cache.get_entity_limits(
                entity_id,
                schema.DEFAULT_RESOURCE,
                self.get_limits,
            )
            if entity_default_limits:
                return entity_default_limits, None, "entity_default"

        # Resource-level defaults
        resource_limits = await self._config_cache.get_resource_defaults(
            resource,
            self.get_resource_defaults,
        )
        if resource_limits:
            return resource_limits, None, "resource"

        # System-level defaults (includes on_unavailable)
        system_limits, on_unavailable = await self._config_cache.get_system_defaults(
            self.get_system_defaults,
        )
        if system_limits:
            return system_limits, on_unavailable, "system"

        # Nothing found
        return None, on_unavailable, None

    async def resolve_on_unavailable(self) -> OnUnavailableAction:
        """Resolve on_unavailable from system config, with caching fallback.

        Returns the on_unavailable action from system config. Caches the
        value after first successful load so it's available as fallback
        when DynamoDB is unreachable. Defaults to "block" if no system
        config exists and no cached value is available.
        """
        try:
            _, on_unavailable = await self._config_cache.get_system_defaults(
                self.get_system_defaults,
            )
            if on_unavailable is not None:
                self._on_unavailable_cache = on_unavailable
                return on_unavailable
            # System config exists but on_unavailable not set — use cache or default
            if self._on_unavailable_cache is not None:
                return self._on_unavailable_cache
            return "block"
        except Exception:
            # DynamoDB unreachable — use cached value or default
            if self._on_unavailable_cache is not None:
                logger.warning(
                    "DynamoDB unavailable, using cached on_unavailable=%s",
                    self._on_unavailable_cache,
                )
                return self._on_unavailable_cache
            logger.warning("DynamoDB unavailable, defaulting on_unavailable=block")
            return "block"

    async def invalidate_config_cache(self) -> None:
        """Invalidate all cached config entries (ADR-122)."""
        await self._config_cache.invalidate_async()
        self._on_unavailable_cache = None

    def get_cache_stats(self) -> CacheStats:
        """Get config cache performance statistics (ADR-122)."""
        return self._config_cache.get_stats()


# Type assertion: Repository implements RepositoryProtocol
# This is verified at type-check time by mypy, not at runtime
if TYPE_CHECKING:
    from .repository_protocol import RepositoryProtocol

    _: RepositoryProtocol = cast(Repository, None)
