Saltar a contenido

Opcionales

Funciones con coste propio en AWS, apagadas por defecto: sólo se activan al pasar la opción o instanciar la clase. Cada docstring lleva su bloque "Coste y activación". Guías: Secretos, Índice de metadatos, Montajes S3, Tamaños, Eventos y webhooks, Exportación OTLP, Pasarela de secretos, Templates declarativos, Pilas opcionales, Volúmenes EFS (experimental), Dominio propio (experimental) y Funciones opcionales.

Pilas opcionales

OptionalStacks(*, region: str | None = None, session: boto3.session.Session | None = None, provisioner: StackProvisioner | None = None)

Construirlo no hace ninguna llamada a AWS.

components() -> tuple[StackComponent, ...]

Metadata pura del catálogo; nunca llama a AWS.

parameter_changes(component: str | StackComponent, *, stack_name: str | None = None, parameters: dict[str, str] | None = None, artifact_bucket: str | None = None) -> tuple[ParameterChange, ...]

Qué parámetros cambiaría deploy() con estos mismos argumentos, sin desplegar nada (sólo un DescribeStacks): lo que rayito stack deploy imprime antes de pedir confirmación. Los que no se pasan y la pila ya tiene no aparecen: deploy() los conserva.

deploy(component: str | StackComponent, *, stack_name: str | None = None, parameters: dict[str, str] | None = None, artifact_bucket: str | None = None, tags: dict[str, str] | None = None, wait: bool = True, wait_timeout: float = DEFAULT_WAIT_TIMEOUT_SECONDS) -> StackStatus

Crea la pila o, si ya existe, la actualiza. Al crear, los parámetros no pasados toman su valor por defecto del catálogo; al actualizar, los no pasados conservan el valor con el que la pila está desplegada (UsePreviousValue), así redesplegar sin repetir cada parámetro no deshace la configuración anterior.

AsyncOptionalStacks(*, region: str | None = None, session: boto3.session.Session | None = None, provisioner: StackProvisioner | None = None)

StackComponent(name: str, description: str, cost: CostStatement, parameters: tuple[StackParameter, ...] = (), artifacts: tuple[StackArtifact, ...] = (), capabilities: tuple[str, ...] = (), supported: bool = True) dataclass

StackParameter(name: str, description: str, required: bool = False, default: str | None = None, no_echo: bool = False) dataclass

StackStatus(name: str, state: str | None, outputs: dict[str, str] = dict(), reason_code: str | None = None, parameters: dict[str, str] = dict()) dataclass

CostStatement(creates: tuple[str, ...], idle_monthly: str, per_use: tuple[str, ...] = (), removal: str = 'destroy() borra todo lo que esta pila creó', source: str = '') dataclass

El bloque "Coste y activación" de cada componente, de forma estructurada: creates son los recursos que la pila crea, idle_monthly el coste en reposo, per_use el coste por uso (vacío si no lo hay), removal qué se conserva o se borra con destroy y source la referencia con fecha de la página de precios consultada.

StackArtifact(name: str, parameter_key: str, bucket_parameter_key: str | None = None) dataclass

Un fichero que deploy() sube a artifact_bucket antes de desplegar la plantilla (código Lambda); parameter_key es el parámetro de la plantilla al que se pasa la clave S3 resultante (rayito/stacks/<componente>/<sha256>.zip, _packaging.artifact_key). bucket_parameter_key, si la plantilla también necesita el bucket (p. ej. Code.S3Bucket de una Lambda), es el parámetro al que deploy() pasa el mismo artifact_bucket: así ni la CLI genérica ni la fachada de la función tienen que repetirlo como --param.

Montajes S3

S3Mount(bucket: str, prefix: str = '', read_only: bool = True, allow_overwrite: bool = False, allow_delete: bool = False) dataclass

Un bucket (o un prefijo suyo) montado en el guest por rayd vía mount-s3/FUSE. Sólo tiene efecto sobre rayito-base-caps (o una variante derivada por tamaño): _role_policy.require_caps_for lo comprueba antes de lanzar cuando la imagen se conoce.

read_only=True (el valor por defecto) es la postura de mínimo privilegio: allow_overwrite/allow_delete sólo tienen efecto, y sólo se aceptan, con read_only=False.

Coste y activación

Activa: Sandbox.create(mounts={"/mnt/data": S3Mount(...)}); construir un S3Mount no llama a AWS. Recursos y llamadas AWS: ningún recurso nuevo; mount-s3 hace las peticiones S3 normales (GetObject/ListObjectsV2, y PutObject/DeleteObject con read_only=False) con el execution role del sandbox. Coste aproximado: $0 propio de Rayito; las peticiones y el almacenamiento normales de S3 del bucket (us-east-1, consultado 2026-10-01); la pila s3-mounts es $0 (sólo IAM). IAM: RayitoS3MountAccess (rayito stack deploy s3-mounts) en el execution role, y el bucket en RAYITO_ALLOWED_MOUNT_BUCKETS de la imagen. Cómo apagarla: no pases mounts=; rayito stack destroy s3-mounts quita la política (no borra objetos ni el bucket). Ejemplo: Sandbox.create("rayito-base-caps", execution_role_arn=role_arn, mounts={"/mnt/data": S3Mount(bucket="mi-bucket", prefix="team7/")})

MountStatus(state: MountPhase, last_error_class: str | None = None) dataclass

Lo que sbx.mounts[path] expone, leído de ConfigureStatus (o del resultado de la última Configure): last_error_class es None salvo cuando state == "failed".

Eventos y webhooks

LifecycleEvents(*, stack_name: str = DEFAULT_STACK_NAME, region: str | None = None, session: boto3.session.Session | None = None, stacks: OptionalStacks | None = None, gateway: EventsGateway | None = None)

deploy(*, artifact_bucket: str, log_group_name: str, reconciler_interval_minutes: int = DEFAULT_RECONCILER_INTERVAL_MINUTES, tags: dict[str, str] | None = None, wait: bool = True) -> StackStatus

Despliega infra/events-webhooks.yaml.

Coste y activación

Activa: una llamada explícita a este método (o rayito events deploy). Recursos y llamadas AWS: un secreto de Secrets Manager (la clave HMAC del stack), una tabla DynamoDB on-demand con streams, tres funciones Lambda con un log group cada una, una suscripción de CloudWatch Logs, dos colas SQS de fallos, una regla de EventBridge Scheduler (rate(reconciler_interval_minutes), mínimo 2) y cuatro políticas IAM gestionadas. Coste aproximado: $0,40/mes el secreto; DynamoDB, Lambda y SQS son on-demand/por uso ($0 en reposo); el Scheduler invoca el reconciliador cada reconciler_interval_minutes minutos (~$0,0000002 por invocación, us-east-1, 2026-09-30). IAM: la pila emite una política por tarea; adjunta a cada identidad sólo la suya: EventsLauncherPolicy (events=: lee la clave del stack), EventsReaderPolicy (get_events: sólo filas de eventos) y EventsWebhookAdminPolicy (register_webhook/ list_webhooks/delete_webhook: sólo filas WEBHOOK). Todas incluyen DescribeStacks sobre la pila. EventsOperatorPolicy (la unión de las tres) queda obsoleta. Cómo apagarla: destroy() (fuerza el borrado del secreto: cualquier webhook registrado deja de poder verificarse); desvincula antes esas políticas de quien las tenga, o la pila acaba en DELETE_FAILED (AWS_API_NOTES.md Q108). Ejemplo: ev = LifecycleEvents() ev.deploy(artifact_bucket="mi-bucket", log_group_name="/rayito/rayito-base")

destroy(*, wait: bool = True) -> None

Borra la pila entera: el secreto del stack (force-delete), la tabla con todos sus eventos y webhooks, las tres Lambdas, la suscripción, las colas de fallos y el scheduler. No toca los secretos de cada webhook (rayito/webhooks/..., de SecretStore) ni el log group de la imagen, que esta pila nunca creó. Si alguna de sus políticas (EventsLauncherPolicy, EventsReaderPolicy, EventsWebhookAdminPolicy o EventsOperatorPolicy) sigue vinculada a algún usuario o rol, CloudFormation no puede borrarla y la pila termina en DELETE_FAILED (StackException): desvincúlala y repite (AWS_API_NOTES.md Q108).

AsyncLifecycleEvents(*, stack_name: str = DEFAULT_STACK_NAME, region: str | None = None, session: boto3.session.Session | None = None, stacks: OptionalStacks | None = None, gateway: EventsGateway | None = None)

WebhookInfo(webhook_id: str, url: str, types: tuple[str, ...]) dataclass

Lo que register_webhook/list_webhooks exponen; nunca el secreto (ni su nombre en Secrets Manager, ni mucho menos su valor).

EventRecord(event_id: str, sandbox_id: str, kind: str, kill_reason: str | None, generation: int, occurred_at_ms: int, image_arn: str, image_version: str) dataclass

Una fila de get_events(). sandbox_execution_id es f"{sandbox_id}#{generation}" (documentado para el payload del webhook); sandbox_template_id es el ARN de la imagen.

Templates declarativos

Template(spec: TemplateSpec | None = None)

Builder inmutable de un TemplateSpec. Template() crea uno vacío (sin imagen base): llama a from_base_image() antes de compilar.

from_base_image(name: str = DEFAULT_BASE_IMAGE_NAME, *, version: str | None = None) -> Self

Parte del zip de codeArtifact de name (ya publicada con rayito image publish): Template.build() lo descarga (s3:GetObject), le añade las instrucciones de cable compiladas y vuelve a subir el resultado. version fija una versión en vez de la última ACTIVE.

skip_cache(*, skip_cache: bool = True) -> Self

0.6 no tiene caché de capas propia (investigación §3.4): esto hace que Template.build()/build_in_background() se comporten como con force=True — envían un build nuevo aunque ya exista una versión con la misma configuración.

set_start_cmd(start_cmd: str, ready_cmd: ReadyCommand | str | None = None, *, user: str = DEFAULT_TEMPLATE_USER, workdir: str | None = None, envs: dict[str, str] | None = None) -> Self

Hornea /etc/rayito/template.json (rayito.template/1). Al arrancar, un rayd 0.6 o posterior lo lee y, antes del /ready del build (el snapshot ya lo lleva en marcha), lanza start_cmd como proceso gestionado (visible en commands.list()); si hay ready_cmd, /ready responde 503 hasta que el comando sale con 0, y 500 al agotarse ReadyPoll.timeout_seconds (wait_for_port/wait_for_url/ wait_for_process/wait_for_file en _ready_cmds.py; una cadena cruda se sondea con la cadencia por defecto, 0,5 s durante 60 s). Requiere que la imagen base se haya publicado con rayd 0.6 o posterior: un rayd anterior ignora el fichero.

to_dockerfile() -> str

El Dockerfile real que Template.build() sube (investigación §3.6: to_dockerfile() ya existe en E2B).

to_json() -> str

Serialización estable del TemplateSpec (depurar/diffear un template entre builds; no es lo que rayd lee: eso es /etc/rayito/template.json, un subconjunto sólo del StartSpec).

AsyncTemplate(spec: TemplateSpec | None = None)

Bases: Template

Template con build/build_in_background/get_build_status/ exists en versión async (asyncio.to_thread, como el resto del SDK async: ver sandbox_async/*). El resto de la superficie (DSL, to_dockerfile, to_json) es pura y se hereda tal cual.

BuildInfo(template_id: str, build_id: str, alias: str) dataclass

Lo que devuelve un build que terminó bien. template_id es el ARN de la imagen (pásalo a Sandbox.create(template_id)); build_id es "<imageVersion>/<buildId>", para get_build_status/template logs.

BuildHandle(arn: str, version: str, name: str, region: str | None = None, session: Any = None) dataclass

Lo que devuelve build_in_background(): deja la petición ya aceptada (create-microvm-image/update-microvm-image respondió) y guarda lo necesario para que get_build_status() vuelva a preguntar sin repetir el build.

BuildStatus(state: BuildState, info: BuildInfo | None = None, error_message: str | None = None) dataclass

El estado de un build en curso o terminado. info/error son mutuamente excluyentes y ambos None mientras state == "IN_PROGRESS".

ReadyCommand(cmd: str, poll: ReadyPoll) dataclass

Un ready_cmd compilado: el comando de shell y el sondeo con el que rayd lo ejecuta.

timeout(seconds: float) -> ReadyCommand

Sustituye ReadyPoll.timeout_seconds por seconds (debe ser > 0); la cadencia de sondeo no cambia.

ReadyPoll(interval_seconds: float, timeout_seconds: float) dataclass

Cuánto y con qué cadencia rayd sondea ready_cmd antes de declarar el build fallido (ver _ready_cmds.py).

TemplateSpec(base: BaseImageRef | None = None, steps: tuple[WireStep, ...] = tuple(), start: StartSpec | None = None, skip_cache: bool = False) dataclass

El estado inmutable que acumula el builder Template (_dsl.py): de qué imagen parte, en qué orden se compilan las instrucciones de cable, y el StartSpec opcional. skip_cache no activa ninguna caché de capas propia (0.6 no tiene una, investigación §3.4): sólo fuerza Template.build(..., force=True) a reconstruir en vez de reusar una versión existente con la misma configuración.

BaseImageRef(kind: str, name: str, version: str | None = None) dataclass

Qué imagen compone from_base_image(). name es el nombre o ARN de la imagen ya publicada (rayito-base, rayito-base-caps, ...); version fija una versión en vez de "la última ACTIVE" (por defecto None).

StartSpec(start_cmd: str, ready_cmd: str | None = None, user: str = DEFAULT_TEMPLATE_USER, workdir: str | None = None, envs: tuple[tuple[str, str], ...] = (), ready_poll: ReadyPoll | None = None) dataclass

Lo que horneado en /etc/rayito/template.json lee rayd (dominio compartido con rayd_core::template::StartSpec, mismo esquema JSON): el comando que arranca como proceso gestionado y, si lo hay, el que rayd sondea antes de responder 200 en /ready.

RunStep(cmd: str) dataclass

RUN <cmd>, ejecutado con /bin/sh -c.

CopyStep(src: str, dst: str) dataclass

COPY <src> <dst>: src es una ruta relativa al contexto (bajo el directorio desde el que se llama a Template.build), dst una ruta absoluta en la imagen.

EnvStep(key: str, value: str) dataclass

ENV <key>=<value>: horneado en la imagen y en todos sus snapshots. Nunca un secreto (investigación §3.7): set_envs no acepta nada que deba rotar.

WorkdirStep(path: str) dataclass

WORKDIR <path>: afecta a los RunStep/CopyStep posteriores y al workdir por defecto del start cmd.

UserStep(user: str) dataclass

USER <user>: afecta a los RunStep posteriores. DockerfileRenderer siempre vuelve a USER root antes del CMD de rayd final (investigación §3.5: "rayd sigue siendo PID 1").

wait_for_port(port: int) -> ReadyCommand

Listo cuando algo escucha en port (TCP, loopback).

wait_for_url(url: str, *, status: int = 200) -> ReadyCommand

Listo cuando url responde status (curl, sólo cabeceras).

wait_for_process(name: str) -> ReadyCommand

Listo cuando hay un proceso cuyo nombre contiene name (pgrep -f).

wait_for_file(path: str) -> ReadyCommand

Listo cuando path existe.

Secretos

SecretStore(*, region: str | None = None, session: boto3.session.Session | None = None, prefix: str = DEFAULT_SECRET_PREFIX, kms_key_id: str | None = None)

CRUD de secretos de Rayito sobre AWS Secrets Manager, en tu cuenta.

Coste y activación

Activa: SecretStore(...) es la opción explícita del CRUD; construirlo no llama a AWS: el cliente boto3 secretsmanager se crea en la primera llamada a un método. Recursos y llamadas AWS: un secreto de Secrets Manager por create (CreateSecret); update = DescribeSecret + PutSecretValue (+ UpdateSecret si cambias metadata); get_info/exists = DescribeSecret; list = ListSecrets (eventualmente consistente: un secreto recién creado o cambiado puede tardar ~3-5 s en salir); destroy = DescribeSecret + DeleteSecret (sin ventana de recuperación). Coste aproximado: $0,40 por secreto y mes hasta destroy + $0,05 por 10 000 llamadas (us-east-1, 2026-09-30). IAM: secretsmanager:CreateSecret, PutSecretValue, UpdateSecret, DescribeSecret, DeleteSecret sobre ...:secret:rayito/* y secretsmanager:ListSecrets sobre * (política RayitoSecretsAdmin de infra/secrets-access.yaml); con kms_key_id=, kms:GenerateDataKey y kms:Decrypt sobre esa clave. Logs: ni el valor ni el nombre en los logs de Rayito; el DEBUG de botocore sí imprime el valor (no lo actives con secretos). Cómo apagarla: no instancies SecretStore; borra con destroy() los secretos que ya no uses (se facturan hasta entonces). Ejemplo: store = SecretStore(region="us-east-1") info = store.create("openai", "sk-...", metadata={"team": "ml"}) store.update("openai", "sk-nuevo") page = store.list(limit=20) store.destroy("openai")

name es un nombre bajo prefix (rayito/ por defecto; '' para ninguno) o un ARN completo. Los errores nunca repiten el nombre ni el valor. Una instancia es reutilizable y segura entre hilos.

region: str | None property

La región pedida (o la de la sesión); None = la cadena por defecto.

create(name: str, value: str, *, metadata: Mapping[str, str] | None = None) -> SecretInfo

CreateSecret con la versión 1. Si el nombre aún se está borrando (destroy reciente: AWS tarda ~20-30 s en liberarlo), reintenta con backoff y jitter durante CREATE_RETRY_BUDGET_SECONDS (60 s) con el mismo ClientRequestToken y después lanza SecretException. created_at/updated_at son el reloj del cliente (get_info da los de AWS).

update(name: str, value: str, *, metadata: Mapping[str, str] | None = None) -> SecretInfo

Nueva versión n + 1 (DescribeSecret + PutSecretValue); con metadata, además la sustituye (UpdateSecret). Los lectores con SecretCache ven el valor nuevo al vencer su TTL o con refresh(). Más de una llamada cada 600 s para el mismo secreto avisa una vez (UserWarning).

get_info(name: str) -> SecretInfo

DescribeSecret. Un secreto programado para borrarse es SecretNotFoundException.

list(*, limit: int | None = None, next_token: str | None = None) -> SecretPage

Una página de ListSecrets filtrada por el prefijo (sin los programados para borrarse). limit 1-100. Es eventualmente consistente: un secreto recién creado o actualizado puede tardar ~3-5 s en aparecer (o en reflejar el cambio); get_info no.

destroy(name: str) -> bool

DescribeSecret y, si existe, DeleteSecret(ForceDeleteWithoutRecovery=True): deja de facturarse y no se puede recuperar. True si lo borró; False si no existía (o ya estaba programado para borrarse, u otro lo borró a la vez), sin llamar a DeleteSecret: el borrado forzado de AWS no distingue un nombre inexistente.

read_value(ref: SecretRef) -> str

GetSecretValue de una referencia; lo usa SecretCache (y sólo ella: nadie más debería leer valores sin caché). Nunca lee un secreto de firma de webhook (refuse_webhook_signing_secret).

cache_identity() -> tuple[str | None, object | None]

La parte (región, credenciales) de la clave de SecretCache.

api() -> SecretsManagerApi

El cliente secretsmanager, construido en el primer uso.

SecretCache(*, ttl_seconds: int | float = DEFAULT_TTL_SECONDS, store: SecretStore | None = None, region: str | None = None, session: boto3.session.Session | None = None, prefix: str | None = None)

Caché en memoria de valores de secretos: lo que hace que secrets= nunca traiga el secreto en cada llamada.

Coste y activación

Activa: secret_cache=SecretCache(ttl_seconds=300) fija la caché (y su TTL) que usa secrets=; sin él, secrets= usa una caché compartida del proceso por (región, sesión) con TTL 300, creada la primera vez. Construirla no llama a AWS. Recursos y llamadas AWS: GetSecretValue en el primer uso de cada (región, credenciales, secreto, versión) y otra vez sólo al vencer el TTL o con refresh(); un acierto hace 0 llamadas. Una sola petición en vuelo por clave (10 hilos a la vez = 1 llamada). Coste aproximado: con TTL 300, ≤ 12 llamadas/hora por secreto y proceso ≈ $0,04/mes ($0,05 por 10 000 llamadas, us-east-1, 2026-09-30); el secreto en sí cuesta $0,40/mes aparte. IAM: secretsmanager:GetSecretValue (política RayitoSecretsReader de infra/secrets-access.yaml) y kms:Decrypt si el secreto usa una CMK. Cómo apagarla: no pases secrets= ni secret_cache=. ttl_seconds=0 no existe (sería traer en cada llamada): 1..86400. Ejemplo: cache = SecretCache(ttl_seconds=600, region="us-east-1") sbx = Sandbox.create(secrets={"OPENAI_API_KEY": "openai"}, secret_cache=cache) sbx.commands.run("python agent.py") # 1 GetSecretValue sbx.commands.run("python agent.py") # 0: acierto cache.refresh("openai") # fuerza una lectura nueva

Los valores viven sólo en la memoria de este proceso; repr/str nunca los muestran. Un secreto que no existe no se guarda.

get(secret: str | SecretRef) -> str

El valor (de la caché si sigue vigente; si no, GetSecretValue). Cada llamada descarta antes los valores vencidos de esta caché.

aget(secret: str | SecretRef) -> str async

get para asyncio: un acierto no sale del bucle; un fallo corre en asyncio.to_thread con la misma petición única por clave.

refresh(name: str | SecretRef | None = None) -> None

Fuerza una lectura nueva: con name, la hace ya (y lanza si el secreto no existe); sin él, descarta todo y cada secreto se relee en su siguiente uso.

invalidate(name: str | SecretRef | None = None) -> None

Descarta los valores de name (todas sus versiones si es un str) o de todos.

sweep() -> int

Descarta los valores vencidos (sin llamar a AWS) y devuelve cuántos: un valor no sobrevive en memoria a su TTL más allá del siguiente uso de la caché.

SecretRef(name: str, version_id: str | None = None, version_stage: str | None = None) dataclass

Una referencia a un secreto, nunca su valor: name es un nombre bajo el prefijo del SecretStore ("openai" → rayito/openai) o un ARN completo (arn:..., que se usa tal cual). version_id o version_stage (como mucho uno) fijan la versión; sin ninguno se lee AWSCURRENT. Una cadena simple en secrets= equivale a SecretRef(name).

selector: tuple[str, str] property

La versión pedida como parte de la clave de caché.

SecretInfo(secret_id: str, name: str, version: int, metadata: dict[str, str], created_at: datetime, updated_at: datetime) dataclass

Metadatos de un secreto (los mismos campos que e2b.SecretInfo). El valor es de sólo escritura y nunca vuelve en ninguna lectura.

secret_id es el ARN de Secrets Manager (E2B usa sec_…); version es el entero que Rayito codifica en el ClientRequestToken (1 al crear, +1 en cada update; 0 si la versión actual no la escribió Rayito); metadata vive en Description (≤ 2048 caracteres codificados).

SecretPage(items: list[SecretInfo], next_token: str | None) dataclass

Una página de SecretStore.list(): next_token es None en la última.

Índice de metadatos

DynamoDbIndex(table_name: str, *, region: str | None = None, session: boto3.session.Session | None = None, on_write_failure: WriteFailurePolicy = 'terminate', ttl_margin_seconds: int = DEFAULT_TTL_MARGIN_SECONDS)

Índice de metadatos de sandboxes sobre una tabla DynamoDB de tu cuenta.

Coste y activación

Activa: index=DynamoDbIndex("rayito-sandboxes") en Sandbox.create() (escribe una fila por sandbox), Sandbox.list()/paginate() con metadata= (filtra también SUSPENDED sin sondear Health) y PoolConfig(index=) (escribe al rellenar). Construirlo no llama a AWS: el cliente boto3 dynamodb se crea en su primer uso. Recursos y llamadas AWS: dynamodb:PutItem (1 por sandbox creado, condicional attribute_not_exists(pk)) y dynamodb:BatchGetItem (1 por página de list-microvms, ≤ 100 claves, lectura eventualmente consistente). Ningún recurso: la tabla la despliegas tú (infra/metadata-index.yaml). Nunca DeleteItem (el TTL borra). Coste aproximado: on-demand, us-east-1, consultado 2026-09-30: ~1 WRU por create ≈ $0,000000625; 0,5 RRU por ítem listado; $0,25/GB-mes; 10 000 sandboxes/mes < $0,10; tabla vacía $0 (https://aws.amazon.com/dynamodb/pricing/on-demand/). IAM: dynamodb:PutItem (escritor) y dynamodb:BatchGetItem (lector) sobre el ARN de la tabla, en las credenciales del llamante. Cómo apagarla: no pases index= (None por defecto) y borra el stack de la tabla si ya no la usas. Ejemplo: idx = DynamoDbIndex("rayito-sandboxes", region="us-east-1") sbx = Sandbox.create(metadata={"user": "42"}, index=idx) sbx.pause() items = list(Sandbox.list(metadata={"user": "42"}, states=["SUSPENDED"], index=idx))

on_write_failure decide qué hace create() si PutItem falla: 'terminate' (por defecto) termina el MicroVM recién lanzado (salvo keep_on_failure=True) y lanza IndexWriteException; 'warn' avisa en el logger rayito.index y devuelve el sandbox, que no aparecerá en los listados con índice. ttl_margin_seconds es el margen sobre la vida máxima del sandbox antes de que la fila caduque. region/session son los de la tabla (por defecto la cadena de boto3); sin región, create() lanza InvalidArgumentException antes de run-microvm, sin lanzar ningún MicroVM. Reutilizable y seguro entre hilos.

now() -> float

Segundos epoch con los que se descartan filas caducadas.

put(record: IndexRecord) -> None

PutItem condicional: nunca sobrescribe una fila existente.

batch_get(sandbox_ids: Sequence[str]) -> dict[str, IndexRecord]

Las filas vigentes de sandbox_ids, en trozos de 100 con lectura eventualmente consistente; reintenta UnprocessedKeys con un backoff acotado y, si siguen sin procesar, lanza (nunca una lista incompleta en silencio). Las filas caducadas o con otra forma se descartan.

prepare() -> None

Comprobación previa sin llamadas a AWS que create() hace antes de run-microvm: construye el cliente dynamodb (construirlo no llama a AWS) y lanza InvalidArgumentException si no hay región, así un error de configuración nunca lanza (ni factura) un MicroVM.

api() -> DynamoDbApi

El cliente dynamodb, construido en el primer uso.

Tamaños

SizeRequest(memory_mib: int) dataclass

size=SizeRequest(memory_mib=3000): una petición explícita en MiB, para cuando ningún nombre corto ("512mb".."8gb") encaja exactamente. Siempre se redondea hacia arriba, nunca hacia abajo: un sandbox nunca recibe menos memoria de la que se pidió.

Pasarela de secretos

SecretGateway(upstream: str, headers: Mapping[str, str | SecretRef], allow: Sequence[tuple[str, str]], rate_per_minute: int = 0) dataclass

Una entrada de gateways={"nombre": SecretGateway(...)} en Sandbox.create(): un único upstream fijo al que rayd reenvía, inyectando en cada petición permitida el valor de cada cabecera de headers (resuelto de Secrets Manager por su nombre, nunca aquí) y rechazando lo que allow no cubre o exceda rate_per_minute.

Coste y activación

Activa: gateways={"nombre": SecretGateway(...)} en Sandbox.create() (o SandboxPool.take(gateways=)); sin él, rayd no abre ningún listener de loopback y el SDK no hace ninguna llamada a ConfigureSandbox ni a Secrets Manager. Recursos y llamadas AWS: secretsmanager:GetSecretValue una vez por cabecera y TTL de la SecretCache que ya usa secrets= (un acierto no llama a AWS), y una por cabecera en cada sbx.gateways.refresh(); ningún recurso nuevo (reutiliza infra/secrets-access.yaml). Coste aproximado: el de SecretCache ($0,05 por 10 000 llamadas, us-east-1, 2026-09-30) más el del secreto en sí si no existía ya. IAM: secretsmanager:GetSecretValue en las credenciales del llamante, igual que secrets= (no el execution role: la pasarela corre en rayd, no necesita rol de ejecución). Cómo apagarla: no pases gateways= (por defecto None). Ejemplo: sbx = Sandbox.create(gateways={ "anthropic": SecretGateway( upstream="https://api.anthropic.com", headers={"x-api-key": "anthropic"}, allow=[("POST", "/v1/messages")], rate_per_minute=600, ) }) url = sbx.gateways["anthropic"].url sbx.commands.run("python agent.py", envs={"ANTHROPIC_BASE_URL": url})

GatewayStatus(port: int, last_error_class: str | None = None) dataclass

Espejo de SecretGatewayRouteStatus: el estado de una ruta tras el Configure (o ConfigureStatus) más reciente.

url: str property

"http://127.0.0.1:<port>": no es secreto (nada más que uid 1000 puede alcanzar el puerto dentro del sandbox).

GatewayHandle(statuses: Mapping[str, GatewayStatus], *, refresher: Callable[[], Mapping[str, GatewayStatus]] | None = None, async_refresher: Callable[[], Awaitable[Mapping[str, GatewayStatus]]] | None = None, recovered: bool = False)

Bases: Mapping[str, GatewayStatus]

sbx.gateways: un mapeo de sólo lectura nombre -> GatewayStatus. Sin gateways= es siempre un GatewayHandle vacío cuyo refresh()/ arefresh() no hacen nada, así que llamarlos nunca es una rama especial para quien los use. Con gateways=, releen cada cabecera de Secrets Manager (aunque la SecretCache no haya vencido) y mandan un Configure nuevo — la forma de rotar un secreto sin recrear el sandbox. Cada ruta conserva su puerto; si rayd rechaza la sección, lanzan y el estado anterior sigue en pie.

Sandbox (síncrono) pasa refresher; AsyncSandbox pasa async_refresher (mismo patrón que SecretCache.get/aget): cada uno sabe envolver al otro, así que da igual cuál dio el handle.

recovered: bool property

True si connect() lo reconstruyó desde ConfigureStatus (no sabe rotar secretos); False si viene de create(gateways=).

refresh() -> None

Para Sandbox. Un AsyncSandbox usa arefresh(): llamar a refresh() desde una corrutina bloquearía su propio loop.

arefresh() -> None async

Para AsyncSandbox. Sobre un handle síncrono, corre el refresher en un hilo (como SecretCache.aget sobre get).

Exportación OTLP

Guía: Exportación OTLP.

TelemetryExport(interval_s: int = DEFAULT_INTERVAL_S, service_name: str = DEFAULT_SERVICE_NAME, names: NameStyle = 'rayito', auth: OtlpAuth = OtlpAuth.execution_role()) dataclass

telemetry= de Sandbox.create()/AsyncSandbox.create(). Validado al construirse: un valor fuera de rango es InvalidArgumentException antes de run-microvm, nunca una sección rechazada por rayd después de lanzar la MicroVM.

OtlpAuth(kind: Literal['execution_role', 'bearer'], secret_name: str | None = None) dataclass

Cómo firma rayd cada exportación (research §6.3: opciones B1/B1').

execution_role() es SigV4 sobre las credenciales IMDS del execution role: exige rayito-base-caps (o una variante derivada, por tamaño). bearer(secret_name=...) es experimental (B1'): secret_name se resuelve igual que en secrets= y SecretStore, bajo el prefijo rayito/ ("otlp-key" lee rayito/otlp-key; un ARN completo se usa tal cual), así que basta la política RayitoSecretsReader de infra/secrets-access.yaml. El valor se lee al enviar la sección por la SecretCache compartida (_section.resolve_bearer_token) y viaja a rayd por ConfigureSandbox, nunca por variables de entorno (regla 4 de ADR-014) ni en texto plano en ningún lado; funciona en rayito-base, sin caps.

TelemetryHealth(exported: int = 0, dropped: int = 0, last_error_class: str | None = None) dataclass

sbx.get_telemetry_status(): espejo de TelemetryExportStatus (ConfigureService.ConfigureStatus). Todo ceros/None cuando rayd nunca aplicó una sección (no hay telemetry=, o la imagen no soporta la función).

Volúmenes EFS (experimental)

VolumeStore(*, file_system_id: str, region: str | None = None, session: boto3.session.Session | None = None)

CRUD de volúmenes (access points) de un sistema de ficheros EFS que ya existe (desplegado con rayito stack deploy efs-volumes o uno propio). Una instancia es reutilizable y segura entre hilos.

Coste y activación

Activa: VolumeStore(...); construirlo no llama a AWS (el cliente boto3 efs se crea en el primer método). Experimental: lo que devuelve se monta con Sandbox.create(volumes={ruta: vol}) sobre la imagen opcional rayito-base-caps-efs (rayito image publish --with-efs); ese create() añade una DescribeMountTargets por sistema de ficheros si el volumen no trae mount_target_ip. Recursos y llamadas AWS: ningún recurso nuevo (el sistema de ficheros lo crea infra/efs-volumes.yaml, por separado); create = CreateAccessPoint, get/list = DescribeAccessPoints, destroy = DeleteAccessPoint. Coste aproximado: los access points no tienen cargo propio listado; el sistema de ficheros se factura por infra/efs-volumes.yaml (ver ese componente en rayito stack list). IAM: elasticfilesystem:CreateAccessPoint, DescribeAccessPoints, DeleteAccessPoint (y DescribeMountTargets para montar sin mount_target_ip) sobre el sistema de ficheros (credenciales del LLAMANTE, no del execution role del MicroVM). Cómo apagarla: no instancies VolumeStore; borra con destroy() los volúmenes que ya no uses (el directorio que cubrían no se borra, sólo el access point: borrar los datos exige montar el sistema de ficheros desde un sandbox, fuera de alcance en 0.6). Ejemplo: store = VolumeStore(file_system_id="fs-0123abcd", region="us-east-1") vol = store.create("datos-agente-7") store.get("datos-agente-7") store.list() store.destroy("datos-agente-7")

create(name: str) -> EfsVolume

CreateAccessPoint con uid/gid 1000:1000 y la ruta raíz /rayito-volumes/<name> (research doc §4.5). Idempotente: llamarla dos veces con el mismo nombre no crea dos access points (ClientToken es un hash de file_system_id+nombre). AWS no devuelve el access point ya creado para un ClientToken repetido: responde AccessPointAlreadyExists, que esta función atrapa para devolver get(name) en su lugar, reintentado durante LIST_VISIBILITY_BUDGET_SECONDS porque el listado de EFS tarda en mostrar un access point recién creado (Q125).

get(name: str) -> EfsVolume

DescribeAccessPoints(FileSystemId=...) filtrado por la etiqueta rayito:volume=<name>. VolumeNotFoundException si no existe ninguno con ese nombre.

list() -> tuple[EfsVolume, ...]

Todos los access points del sistema de ficheros con la etiqueta de volumen de Rayito, paginando DescribeAccessPoints hasta agotar NextToken. Eventualmente consistente, como el propio DescribeAccessPoints (Q125): un volumen recién creado puede tardar unos segundos en aparecer y uno recién borrado seguir apareciendo (también en get).

destroy(name: str) -> bool

DeleteAccessPoint; los ficheros bajo su directorio raíz no se borran (honesto: borrarlos exigiría montar el sistema de ficheros, fuera de alcance en 0.6). True si existía y se borró; False si no existía ningún volumen con ese nombre.

api() -> EfsApi

El cliente efs, construido en el primer uso.

AsyncVolumeStore(*, file_system_id: str, region: str | None = None, session: boto3.session.Session | None = None)

EfsVolume(file_system_id: str, access_point_id: str, name: str | None = None, region: str | None = None, read_only: bool = False, mount_target_ip: str | None = None) dataclass

Un volumen EFS: el access point que un sandbox puede montar bajo volumes={path: EfsVolume(...)}. Validado en construcción, sin ninguna llamada a AWS; name/region son metadata del cliente (VolumeStore los rellena al crear o leer uno), nunca parte de la identidad que manda al guest.

VolumeStatus(state: MountState, last_error_class: str | None = None) dataclass

Lo que sbx.volumes[path] reporta (una ConfigureStatus por lectura). last_error_class es una cadena cerrada de efs_volumes.proto (credentials_expired, flush_timeout, stale, iam_denied...), nunca un mensaje de AWS.

EfsVolumes(*, stack_name: str = DEFAULT_STACK_NAME, region: str | None = None, session: boto3.session.Session | None = None, stacks: OptionalStacks | None = None, network: NetworkInspector | None = None, efs: Callable[[], EfsFileSystemApi] | None = None)

Volúmenes EFS en tu propia VPC: comprueba, despliega, consulta y borra el componente efs-volumes. Construirlo no llama a AWS.

Coste y activación

Activa: una llamada explícita a deploy(vpc_id=, subnet_ids=) (o rayito stack deploy efs-volumes --param VpcId=... --param SubnetIds=...). check() es de sólo lectura y no crea nada. Experimental: Sandbox.create(volumes=...) monta sobre la imagen opcional rayito-base-caps-efs (rayito image publish --with-efs), con el execution role y este conector como único egress=: no puede usar además INTERNET_EGRESS. Recursos y llamadas AWS: check() = ec2:DescribeVpcs, DescribeVpcAttribute, DescribeSubnets, DescribeRouteTables. deploy() crea, sólo dentro de la VPC dada y etiquetado: un sistema de ficheros EFS cifrado (clave KMS gestionada por AWS), un mount target por subred, dos grupos de seguridad nuevos (el de los mount targets sólo admite TCP 2049 desde el del conector), el AWS::Lambda::NetworkConnector de salida a la VPC para MicroVMs, su rol de operador y la política RayitoEfsVolumeClient. Nunca modifica la VPC, sus subredes, tablas de rutas, NACLs ni grupos existentes. Coste aproximado: $0 con el sistema de ficheros vacío; $0,30/GB-mes (Standard) y $0,016/GB-mes tras 30 días (IA); Elastic Throughput $0,03/GB leído y $0,06/GB escrito; sin cargo listado por mount targets, access points ni ENIs del conector (us-east-1, 2026-09-11). IAM: el llamante necesita cloudformation:*Stack* sobre la pila, crear los recursos de arriba (CAPABILITY_IAM), los ec2:Describe* de check() y, para borrar el sistema de ficheros, elasticfilesystem:DescribeFileSystems/DescribeMountTargets/ DescribeAccessPoints/DeleteAccessPoint/DeleteFileSystem; el execution role del MicroVM, la política CallerPolicyArn de la salida (ClientMount/ClientWrite sólo sobre este sistema de ficheros y sus access points; nunca ClientWrite sobre read_only_access_point_arns). Cómo apagarla: destroy() borra la pila (conector, grupos, mount targets, rol y política) y conserva el sistema de ficheros con sus datos; destroy(delete_file_system=True) borra además sus access points y el propio sistema de ficheros: todo lo que deploy() creó. Tras rayito stack destroy efs-volumes (que siempre lo conserva), delete_file_system("fs-…") borra el que quedó. Ejemplo: efs = EfsVolumes(region="us-east-1") network = {"vpc_id": "vpc-0123456789abcdef0", "subnet_ids": ["subnet-0123456789abcdef0"]} report = efs.check(network) efs.deploy(network) store = efs.volume_store() efs.destroy(delete_file_system=True)

check(*, vpc_id: str, subnet_ids: Sequence[str] | str) -> EfsNetworkReport

Valida, sin crear nada, que la VPC y las subredes sirven: que existen, que las subredes son de la VPC y están en AZs distintas, que les quedan IPs libres y si la VPC resuelve DNS; avisa de una sola AZ y de cómo saldría a internet un sandbox. El informe incluye lo que deploy() crearía y su coste. Sólo llama a ec2:DescribeVpcs/DescribeVpcAttribute/DescribeSubnets/ DescribeRouteTables.

deploy(*, vpc_id: str, subnet_ids: Sequence[str] | str, allow_write: bool = True, access_point_arns: Sequence[str] = (), read_only_access_point_arns: Sequence[str] = (), connector_name: str | None = None, tags: dict[str, str] | None = None, wait: bool = True) -> StackStatus

Corre check() y, sólo si ningún hallazgo es FAIL, despliega (o actualiza) la pila en la VPC dada. access_point_arns acota la política RayitoEfsVolumeClient a esos access points; vacío, a cualquier access point de la cuenta y región sobre este sistema de ficheros. allow_write=False sólo concede ClientMount. read_only_access_point_arns deja esos access points en sólo lectura aunque allow_write sea True (la política les deniega ClientWrite): es lo que hace de sólo lectura un volumen, porque la opción ro del montaje no impide escribir por el túnel de efs-proxy (AWS_API_NOTES.md §16 Q133). Ver el bloque "Coste y activación" de la clase.

volume_store() -> VolumeStore

Un VolumeStore sobre el sistema de ficheros de la pila ya desplegada (lee FileSystemId de sus salidas). VolumeException si la pila no existe.

destroy(*, delete_file_system: bool = False, wait: bool = True) -> None

Borra la pila. El sistema de ficheros se conserva siempre (DeletionPolicy: Retain) salvo con delete_file_system=True, que después borra sus access points y el propio sistema de ficheros (y con él todos los datos): eso exige wait=True, porque DeleteFileSystem falla mientras quede un mount target.

delete_file_system(file_system_id: str) -> None

Borra un sistema de ficheros que esta pila creó y conservó (p. ej. tras rayito stack destroy efs-volumes, que siempre lo deja): espera a que no le quede ningún mount target, borra sus access points y después el sistema de ficheros, con todos sus datos. Se niega (VolumeException) si no lleva la etiqueta que infra/efs-volumes.yaml le pone (rayito=efs-volumes): nunca borra uno ajeno. Uno que ya no existe es un no-op.

AsyncEfsVolumes(*, stack_name: str = DEFAULT_STACK_NAME, region: str | None = None, session: boto3.session.Session | None = None, stacks: OptionalStacks | None = None, network: NetworkInspector | None = None, efs: Callable[[], EfsFileSystemApi] | None = None)

Ver EfsVolumes (su bloque "Coste y activación" vale igual aquí).

EfsNetworkReport(vpc_id: str, subnet_ids: tuple[str, ...], availability_zones: tuple[str, ...], findings: tuple[NetworkFinding, ...], cost: CostStatement) dataclass

Lo que EfsVolumes.check() devuelve sin crear nada: los hallazgos, las AZs que cubrirían los mount targets y lo que deploy() crearía con su coste (el CostStatement del componente efs-volumes, la misma fuente que rayito stack list).

ok: bool property

True si ningún hallazgo es FAIL: deploy() se niega si no.

NetworkFinding(level: FindingLevel, code: str, message: str) dataclass

Un resultado de la comprobación: code es un conjunto cerrado (vpc-missing, vpc-unavailable, vpc-dns-support, vpc-dns-hostnames, subnet-missing, subnet-other-vpc, subnet-unavailable, subnet-same-az, subnet-free-ips, single-az, internet-egress).

Dominio propio (experimental)

Guía: Dominio propio. Sin verificar aún de punta a punta en AWS real.

CustomDomain(*, public_domain: str, stack_name: str | None = None, kvs_arn: str | None = None, region: str | None = None, session: boto3.session.Session | None = None, provisioner: StackProvisioner | None = None, kvs_writer: KeyValueStoreWriter | None = None, clock: Callable[[], float] = time.time)

Dominio propio sobre CloudFront (experimental). Construirlo no hace ninguna llamada a AWS.

Experimental: la API puede cambiar en una minor. La CloudFront Function de enrutado y la creación de su KeyValueStore se comprobaron en AWS real (AWS_API_NOTES.md Q121, Q140), pero crear la distribución y servir tráfico por ella no (Q141: la cuenta de pruebas lo deniega con una SCP). Si lo pruebas, cuéntalo en un issue de GitHub.

Coste y activación

Activa: CustomDomain(public_domain=...).deploy(certificate_arn=...). Recursos y llamadas AWS: una distribución CloudFront, su CloudFront Function de enrutado y un KeyValueStore (infra/custom-domain.yaml, rayito stack deploy custom-domain). En uso, register()/ unregister()/refresh() llaman a DescribeKeyValueStore/PutKey/ DeleteKey (AWS_API_NOTES.md §29); nada de esto se llama sin invocarlos explícitamente. No hay refresher automático en este cambio (seguimiento no bloqueante, ver _stacks/components/ custom_domain.py): llama a refresh() tú mismo antes de que caduque el JWE de una ruta (DOM-7). Coste aproximado: CloudFront ~$0,085/GB + $0,0075/10 000 peticiones HTTPS (datos salientes, us-east-1); CloudFront Functions ~$0,10 por 1 000 000 de invocaciones (una por petición); KeyValueStore $0 en reposo, ~$0,50 por 1 000 000 de lecturas (las de la Function) y ~$5 por 1 000 000 de llamadas de gestión (PutKey/DeleteKey de register/unregister/refresh) — tres líneas separadas, no una cifra combinada (_stacks/components/custom_domain.py, cifras de lista de CloudFront Functions/KeyValueStore desde su lanzamiento, reconfirmar en la etapa de aceptación AWS). IAM: cloudformation:*Stack* para deploy/destroy (vía OptionalStacks); cloudfront-keyvaluestore:DescribeKeyValueStore/PutKey/DeleteKey sobre el KVS de la pila para register/unregister/refresh. Cómo apagarla: no llames a deploy(); destroy() borra la distribución, la Function y el KVS — ningún dato de sandbox se conserva, las rutas son efímeras. Ejemplo: domain = CustomDomain(public_domain="sbx.example.com") domain.deploy(certificate_arn="arn:aws:acm:us-east-1:...:certificate/...") route = domain.register( "ws-7", 8000, endpoint=sbx.endpoint, jwe=jwe, traffic_token=secrets.token_urlsafe(32), ttl_seconds=2400, ) route.host # "8000-ws-7.sbx.example.com" domain.unregister("ws-7", 8000)

deploy(*, certificate_arn: str, alternate_domain_names: Sequence[str] | None = None, tags: dict[str, str] | None = None, wait: bool = True, wait_timeout: float = CUSTOM_DOMAIN_WAIT_TIMEOUT_SECONDS) -> StackStatus

Despliega (o actualiza) infra/custom-domain.yaml. certificate_arn debe estar en us-east-1 (requisito de CloudFront) y cubrir los nombres de la distribución: *.<public_domain> por defecto.

alternate_domain_names sustituye ese comodín por una lista explícita (_domain.validate_alternate_domain_names; normalmente host_for(alias, port) de rutas conocidas), p. ej. si otra distribución ya tiene *.<public_domain>. Las rutas cuyo host no esté en la lista no llegan a esta distribución. No cambia el coste. Tras desplegar, apunta un CNAME/alias de DNS de cada nombre al DistributionDomainName de la salida.

kvs_arn() -> str

El ARN del KeyValueStore de la pila: el pasado al construir CustomDomain, o el de la última deploy()/status(). Nunca llama a AWS por sí sola: si no hay ninguno, llama a status() primero.

host_for(alias: str, port: int) -> str

El hostname público de una ruta; no llama a AWS ni exige que la ruta esté ya registrada (pura, _domain.route_host).

register(alias: str, port: int, *, endpoint: str, jwe: str, traffic_token: str | None = None, public: bool = False, ttl_seconds: int) -> CustomDomainRoute

Escribe las dos claves de la ruta (j:/m:) con ETag encadenado (_write_route(..., rollback=True): ninguna de las dos claves existía antes para este label, así que si la segunda escritura falla, la primera se borra best-effort antes de relanzar — nunca se deja un JWE vivo sin sus metadatos). Una ruta es pública (sin traffic_token que comprobar) sólo con public=True, nunca por omisión: sin traffic_token ni public=True se rechaza antes de tocar el KVS (SEC-T25 — el valor por defecto nunca es "pública").

refresh(route: CustomDomainRoute, *, jwe: str, ttl_seconds: int) -> CustomDomainRoute

Reescribe j:<label> con un JWE nuevo (el TokenRefresher del transporte ya renueva el suyo a los 45 min, _transport.TOKEN_REFRESH_AFTER_MINUTES; esto hace lo mismo para la copia que vive en el KVS) y también m:<label>, para que su expiración no quede obsoleta: mismo endpoint/hash de traffic_token que route ya tenía, sólo expires_at al día. _write_route(..., rollback=False): a diferencia de register(), aquí ambas claves ya existían con una ruta sana sirviendo tráfico, así que un fallo a medias (p. ej. el PutKey de m: tras el de j:) no debe borrar lo que sí se escribió — eso dejaría una ruta que funcionaba sin su j: o su m: por un error transitorio del KVS.

unregister(alias: str, port: int) -> None

Idempotente: una ruta que ya no está no es un error (ResourceNotFoundException del DeleteKey se traga aquí, no en el adaptador — llegar aquí ya exige que el almacén exista).

AsyncCustomDomain(*, public_domain: str, stack_name: str | None = None, kvs_arn: str | None = None, region: str | None = None, session: boto3.session.Session | None = None, provisioner: StackProvisioner | None = None, kvs_writer: KeyValueStoreWriter | None = None, clock: Callable[[], float] = time.time)

CustomDomain para asyncio (experimental): los mismos métodos, cada uno en un hilo porque boto3 es bloqueante. Construirlo no hace ninguna llamada a AWS. El bloque "Coste y activación" y el alcance de lo verificado en AWS real son los de CustomDomain.

CustomDomainRoute(alias: str, port: int, public_domain: str, endpoint: str, traffic_token_sha256: str, expires_at: datetime) dataclass

Una ruta ya registrada: su hostname y cuándo caduca el JWE que cubre. endpoint/traffic_token_sha256 son los metadatos que register() ya escribió en m:<label>; refresh() los necesita de vuelta para reescribir esa clave con la misma endpoint/hash y sólo expires_at al día.