This is the full developer documentation for Astilba # Overview > Portable infrastructure tools and libraries with explicit correctness boundaries, documented as they exist today. Astilba builds portable infrastructure tools and libraries with explicit correctness boundaries. These docs describe each released or reviewed surface, what its guarantees cost, and which parts remain incomplete. The products are at different lifecycle stages. Create has a public release; Env is a 0.3 public alpha; Cache is a source-reviewed development preview. Read each product’s status page before treating an example as a supported setup. ## Products [Section titled “Products”](#products) ### [Create](/docs/create/) [Section titled “Create”](#create) A deterministic CLI that generates complete TypeScript projects from four maintained recipes. `create-astilba` 0.3.0 is released on npm. [Configure a paste-ready command](https://astilba.com/create/new/), start with [Create your first project](/docs/create/quickstart/), compare the [recipe catalog](/docs/create/recipes/), or check the exact [release and support](/docs/create/release-and-support/) boundary. ### [Env](/docs/env/) [Section titled “Env”](#env) A local-first configuration contract compiler with explicit lifecycles and physically separated browser and server projections. Start with the [overview](/docs/env/overview/), [configure a Node application](/docs/env/quickstart/), choose a [runtime or platform](/docs/env/nodejs/), or check the exact [release and support](/docs/env/release-and-support/) boundary for the 0.3 public alpha. ### [Cache](/docs/cache/) [Section titled “Cache”](#cache) A portable server-side TypeScript cache with explicit invalidation, resilience, and privacy boundaries. Start with the [overview](/docs/cache/overview/), check the [implementation status](/docs/cache/api-status/), review the [source walkthrough](/docs/cache/quickstart/), or learn about the source adapters for [Cloudflare Workers](/docs/cache/cloudflare-workers/) and [React Router](/docs/cache/react-and-server-apps/). Cache is a development preview `@astilba/cache` is not available from npm and has no supported production installation path. The implementation ledger records the exact reviewed source snapshot and incomplete release gates. ## Read the docs in context [Section titled “Read the docs in context”](#read-the-docs-in-context) Each product owns its own version tree and release boundary: * For Create, [Release and support](/docs/create/release-and-support/) records the public package, runtime, recipes, and omitted capabilities supported by 0.3.0. * For Env, [Release and support](/docs/env/release-and-support/) records the 0.3 public-alpha package, runtime, exports, and framework boundary. * For Cache, [Implementation status](/docs/cache/api-status/) is the authoritative ledger for the reviewed source snapshot, partial behavior, placeholders, and missing release gates. Guides show an outcome, concepts explain one mental model, and reference pages enumerate the public surface. If an example and a status ledger ever appear to disagree, follow the ledger and report the stale page. ## For agents [Section titled “For agents”](#for-agents) Fetch the [agent setup prompt](/docs/agent-setup/prompt.md) to connect a supported coding agent to Astilba’s public documentation tools. The prompt configures documentation access only; it does not install a product. Use the public [MCP server](/docs/agents/mcp/) directly to search and read the same published Markdown corpus through a read-only protocol endpoint. # Create > Generate a verified TypeScript project from one maintained Astilba recipe. Astilba Create generates a complete TypeScript project from a small catalog of maintained recipes. It writes the application starting point, pinned toolchain, verification scripts, GitHub workflows, repository guidance, and an ownership manifest in one deterministic operation. `create-astilba` 0.3.0 is available on npm. Its supported public interface is the command-line tool: ```sh npm create astilba@latest ``` ## Choose your next step [Section titled “Choose your next step”](#choose-your-next-step) | Goal | Start here | | ------------------------------------------------ | ------------------------------------------------------------------ | | Decide whether Create fits | [Overview](/docs/create/overview/) | | Assemble a paste-ready command | [Configure a project](https://astilba.com/create/new/) | | Generate and verify a project | [Create your first project](/docs/create/quickstart/) | | Compare the four maintained starting points | [Choose a recipe](/docs/create/recipes/) | | Run Create in CI or an agent workflow | [Automate project creation](/docs/create/automation/) | | Understand its failure and filesystem boundaries | [Deterministic generation](/docs/create/deterministic-generation/) | | Interpret `.astilba/project.json` | [Project manifest](/docs/create/project-manifest/) | | Look up every option and default | [CLI reference](/docs/create/cli-reference/) | | Check the exact supported release surface | [Release and support](/docs/create/release-and-support/) | If this is your first visit, use the configurator or quickstart. If you are integrating Create into automation, read the automation guide and deterministic-generation model together. # Env > Compile one portable configuration declaration into separated, typed browser and server interfaces. Astilba Env is a local-first configuration contract compiler for TypeScript. You declare which values each application artifact needs, when those values may be resolved, and where they may be exposed. Env generates typed browser and server modules from that contract. `@astilba/env` 0.3.0 is a public alpha. It is intended for evaluation and controlled adoption while the contract is still free to improve. ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` Env does not replace your secret manager or `.env` files, and it has no hosted control plane. Generation and checking read only the source values their current operation needs, inside your project. Public build values selected by a browser consumer are deliberately emitted into generated `.build.ts` modules; public build values selected only by a server consumer resolve through a generated server target. Other generated evidence is value-free. Env does not send your values to Astilba. ## Choose your next step [Section titled “Choose your next step”](#choose-your-next-step) | Goal | Start here | | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | | Decide whether Env fits | [Overview](/docs/env/overview/) | | Add Env to a Node application | [Configure a Node application](/docs/env/quickstart/) | | Compare declared names with a provider or platform list | [Check name inventory drift](/docs/env/inventory-and-drift/) | | Check a runtime boundary | [Node.js](/docs/env/nodejs/) or [Browser](/docs/env/browser-runtime/) | | Configure a Cloudflare Worker | [Cloudflare Workers](/docs/env/cloudflare-workers/) | | Integrate a framework | [Vite](/docs/env/vite/) or [Next.js](/docs/env/nextjs/) | | Choose a runtime path | [Node server, Worker deployment target, Next static shell, or Vite browser shell](/docs/env/overview/#choose-the-runtime-path) | | Understand build, deployment, and request values | [Lifecycles and projections](/docs/env/lifecycles-and-projections/) | | Choose built-in or custom validation | [Validation and Standard Schema](/docs/env/validation-and-standard-schema/) | | Serve validated browser configuration | [Deliver browser configuration](/docs/env/browser-delivery/) | | Replace `next-dynamic-env` | [Migrate from next-dynamic-env](/docs/env/migrate-from-next-dynamic-env/) | | Look up builders and codecs | [Declaration reference](/docs/env/declaration-reference/) | | Automate generation, checking, and planning | [CLI reference](/docs/env/cli-reference/) | | Check runtimes, exports, and alpha boundaries | [Release and support](/docs/env/release-and-support/) | Start with the Node quickstart for a new integration. Choose a runtime or framework page when the declaration already exists. Use the migration guide if your application currently exposes runtime configuration through `DynamicEnvScript`, `clientEnv`, or `serverEnv`. # Cache > A portable server-side TypeScript cache with explicit invalidation, resilience, and privacy boundaries. Astilba Cache stores the result of expensive server-side work so later calls can reuse it. It is designed for TypeScript applications that need explicit control over invalidation, failures, and who may share a cached value. > **Development preview:** `@astilba/cache` is not published to npm, the Cache repository is not publicly accessible, and there is no supported production installation path. Cache is a result cache, not a source of truth. Your application still owns the database or upstream service, chooses which values may be shared, and invalidates cached representations after a successful mutation. ## Choose your next step [Section titled “Choose your next step”](#choose-your-next-step) | Goal | Start here | | --------------------------------------------- | ----------------------------------------------------- | | Decide whether the design fits | [Overview](/docs/cache/overview/) | | Check exactly what works today | [Implementation status](/docs/cache/api-status/) | | Review the smallest source example | [Source walkthrough](/docs/cache/quickstart/) | | Learn the storage and invalidation vocabulary | [Cache fundamentals](/docs/cache/core-concepts/) | | Evaluate the current Workers composition | [Cloudflare Workers](/docs/cache/cloudflare-workers/) | | Add request context and response tags | [React Router](/docs/cache/react-and-server-apps/) | If this is your first visit, read the overview before copying an example. The implementation ledger is authoritative whenever another page appears to promise more than the current source provides. # LLMs.txt > Choose a generated text corpus for an agent that reads documentation without MCP. Astilba publishes build-generated text files for language models and other clients that can fetch documentation but cannot connect to an MCP server. Every file is derived from the same public pages as this site. ## Choose a file [Section titled “Choose a file”](#choose-a-file) | File | Use it when | | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | | [`/docs/llms.txt`](https://astilba.com/docs/llms.txt) | The client needs a small index that links to the available documentation sets. | | [`/docs/llms-small.txt`](https://astilba.com/docs/llms-small.txt) | Context is limited and an abridged copy of the documentation is sufficient. | | [`/docs/llms-full.txt`](https://astilba.com/docs/llms-full.txt) | The client can accept the complete published documentation in one response. | | [`/docs/_llms-txt/astilba-create.txt`](https://astilba.com/docs/_llms-txt/astilba-create.txt) | The task concerns only Astilba Create and does not need site-wide material. | | [`/docs/_llms-txt/astilba-env.txt`](https://astilba.com/docs/_llms-txt/astilba-env.txt) | The task concerns only Astilba Env and does not need site-wide material. | | [`/docs/_llms-txt/astilba-cache.txt`](https://astilba.com/docs/_llms-txt/astilba-cache.txt) | The task concerns only Astilba Cache and does not need site-wide material. | The generated files are snapshots of the current deployment. Fetch them again when current behavior or release status matters. ## Access one page [Section titled “Access one page”](#access-one-page) Every published documentation page is also available as Markdown. Replace the trailing slash in a documentation URL with `.md`. The links below are common entry points rather than a complete index; each product text file contains every catalogued page for that product. * [`/docs/create/overview.md`](https://astilba.com/docs/create/overview.md); Create overview and release context. * [`/docs/create/cli-reference.md`](https://astilba.com/docs/create/cli-reference.md); Create command-line reference. * [`/docs/env/overview.md`](https://astilba.com/docs/env/overview.md); Env overview and public-alpha context. * [`/docs/env/quickstart.md`](https://astilba.com/docs/env/quickstart.md); first server-side Env integration. * [`/docs/env/browser-delivery.md`](https://astilba.com/docs/env/browser-delivery.md); validated JSON bootstrap and browser import boundary. * [`/docs/env/declaration-reference.md`](https://astilba.com/docs/env/declaration-reference.md); declaration builders, codecs, consumers, targets, and rules. * [`/docs/env/cli-reference.md`](https://astilba.com/docs/env/cli-reference.md); generation, target checking, planning, JSON formats, and exit statuses. * [`/docs/env/migrate-from-next-dynamic-env.md`](https://astilba.com/docs/env/migrate-from-next-dynamic-env.md); migration guidance for Next.js applications. * [`/docs/env/release-and-support.md`](https://astilba.com/docs/env/release-and-support.md); package, runtime, export, and public-alpha support boundaries. * [`/docs/cache/overview.md`](https://astilba.com/docs/cache/overview.md); Cache overview and preview context. * [`/docs/cache/api-reference.md`](https://astilba.com/docs/cache/api-reference.md); Cache API reference. * [`/docs/agents/mcp.md`](https://astilba.com/docs/agents/mcp.md); MCP Server connection and usage guide. Clients that control request headers can instead request the ordinary page URL with `Accept: text/markdown`. Prefer an individual page when the task is narrow; it consumes less context than a combined documentation set. ## Choose static text or MCP [Section titled “Choose static text or MCP”](#choose-static-text-or-mcp) Use an LLMs.txt file when a client works best with one ordinary HTTP response or cannot configure remote tools. Use the [MCP Server](https://astilba.com/docs/agents/mcp/) at `https://astilba.com/docs/mcp` when the client supports Streamable HTTP and benefits from targeted search, resource discovery, and bounded page reads. Give a compatible coding agent this instruction to connect the public documentation skill and MCP endpoint: ```text Fetch https://astilba.com/docs/agent-setup/prompt.md and follow its instructions. ``` Neither approach installs an Astilba package or grants access to an Astilba account. # MCP Server > Connect an MCP client to search and read Astilba's published documentation. Astilba provides a public, read-only Model Context Protocol endpoint for the same Markdown corpus published on this site. Configure a remote Streamable HTTP server with this URL: ```text https://astilba.com/docs/mcp ``` The endpoint does not require credentials or create a session. It cannot change documentation, access accounts, follow arbitrary URLs, or read private Astilba material. Add it to an MCP client as a remote server. Client configuration formats differ, so use the endpoint above rather than copying configuration intended for another client. For a compatible coding agent, the shortest setup path is: ```text Fetch https://astilba.com/docs/agent-setup/prompt.md and follow its instructions. ``` ## Try it [Section titled “Try it”](#try-it) Once connected, ask the client questions such as: * “Which project recipes does Astilba Create 0.3.0 support?” * “Can a generated Astilba Env target run in a Cloudflare Worker?” * “Is Astilba Cache ready for production?” * “Find the documentation for invalidating related cached values.” * “How do the Cloudflare Workers platform and React Router framework support differ?” The client should search before reading a bounded page or resource. Answers about Create releases should use [Release and support](/docs/create/release-and-support/); Env runtime or compatibility claims should use [Env release and support](/docs/env/release-and-support/); answers about Cache availability should use [Implementation status](/docs/cache/api-status/). ## Tools [Section titled “Tools”](#tools) | Tool | Use it to | | ------------- | ---------------------------------------------------------------------------------------------------- | | `search_docs` | Find published pages by words or API names, with optional product and documentation-version filters. | | `read_doc` | Read a bounded chunk from a page returned by search or resource discovery. | Both tools are read-only, non-destructive, and idempotent. Search returns five results by default and at most 10 per call. A read returns 16,000 UTF-16 characters by default and at most 32,000, avoids splitting a surrogate pair, and reports the next offset when more content remains. ## Resources [Section titled “Resources”](#resources) Every published Markdown page is also exposed as a fixed MCP resource. Resource URIs stay under `astilba.com/docs`; the server does not fetch arbitrary URLs. Prefer resources when your client supports them: discovery gives you the canonical page list and a resource read returns the published document directly. Use `search_docs` and `read_doc` when the client exposes tools more reliably than resources. When you need to make a release or availability claim, read [Release and support](/docs/create/release-and-support/) for Create, [Env release and support](/docs/env/release-and-support/) for Env, or [Implementation status](/docs/cache/api-status/) for Cache before relying on examples elsewhere in the documentation. ## Usage limits [Section titled “Usage limits”](#usage-limits) The endpoint has a Cloudflare-native allowance of 60 units per minute for each source IP within a Cloudflare location: * every accepted POST consumes one unit; * each `resources/read` or `tools/call` operation in that request consumes one additional unit; * clients behind the same outbound IP share capacity; * requests without a Cloudflare source address share one anonymous fallback key. An exhausted per-minute MCP guard returns HTTP 429 with `Retry-After: 60`. Treat this as an abuse guard, not an account quota or a guarantee of globally synchronized capacity. That guard is separate from the Cloudflare account’s Workers request allowance. On the Free plan, Worker-first routes count toward a 100,000-request daily allowance. Cloudflare documents HTTP 429 for matching Static Assets `run_worker_first` requests after that allowance is exhausted; unlike the MCP guard response, that platform response does not carry this endpoint’s `Retry-After: 60` contract. The active account plan remains the authority for the daily allowance. Request bodies are limited to 256,000 bytes. The server accepts up to 16 legacy batch messages only for protocol versions that still permit JSON-RPC batching; current structured-result protocol versions must send individual requests. ## Discovery [Section titled “Discovery”](#discovery) The endpoint publishes machine-readable connection metadata through the [MCP catalog](https://astilba.com/docs/.well-known/mcp/catalog.json), [server card](https://astilba.com/docs/mcp/server-card), and [API catalog](https://astilba.com/docs/.well-known/api-catalog). MCP Server Card discovery is experimental, so its metadata format may evolve while the protocol endpoint remains at the URL above. ## Troubleshooting [Section titled “Troubleshooting”](#troubleshooting) * **The client cannot add a remote Streamable HTTP server.** Use [`/llms.txt`](/docs/llms.txt) or fetch an individual `.md` page instead. * **The server is missing after configuration.** Confirm the endpoint is exactly `https://astilba.com/docs/mcp`, then restart the client if its MCP configuration is only read at startup. * **The client asks for credentials.** This endpoint is public and does not use authentication. Check that the client is connecting to the Astilba endpoint rather than another server. * **A request returns HTTP 429.** Wait for the `Retry-After` interval and reduce parallel tool or resource calls. # Overview > Understand what Astilba Create generates, what it guarantees, and where its responsibility ends. Astilba Create turns a named recipe and a small set of project metadata into an independent TypeScript repository. Use it when you want a maintained starting point with build, test, lint, dependency, security, and repository conventions already connected. The current release is `create-astilba` 0.3.0. It supports four recipe v2 contracts: * `typescript-library` * `react-vite-spa` * `astro-static-site` * `cloudflare-worker-service` Run the interactive command to begin: ```sh npm create astilba@latest ``` Or [configure a paste-ready command](https://astilba.com/create/new/) from the same released catalog. ## Decide whether Create fits [Section titled “Decide whether Create fits”](#decide-whether-create-fits) | You want to… | Fit | | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | Start one of the four supported TypeScript project kinds | This is the intended use case. | | Reproduce a project from explicit inputs in automation | Use non-interactive flags with `--json` and, when appropriate, `--dry-run`. | | Begin with exact dependencies and verification workflows | Every recipe pins its toolchain, packages, and GitHub Actions. | | Create a repository with no template ancestry | Git initialization creates a fresh `main` branch and does not copy Astilba’s commit history. | | Add arbitrary framework, database, authentication, or deployment combinations | Not yet. Create supports complete named recipes, not an unverified feature matrix. | | Merge generated files into an existing directory | Not supported. The destination must not already exist. | | Update or repair an existing generated project | Not in 0.3.0. `doctor` and migration tooling are future work. | | Import a programmatic generator API | Not supported. The npm package exports its CLI and package metadata, not its internal TypeScript modules. | ## Start from a complete recipe [Section titled “Start from a complete recipe”](#start-from-a-complete-recipe) Each recipe supplies project code and the engineering foundation around it: * exact Node.js, pnpm, dependency, and GitHub Action versions; * strict TypeScript; * Ultracite with Oxfmt and Oxlint; * Vitest and Knip; * a frozen-lockfile verification matrix; * Actionlint, Zizmor, OSV-Scanner, PR-title, CodeQL, and dependency-review workflows; * Renovate with a three-day minimum release age; * issue forms, pull-request, security, contribution, and repository-setting guidance; and * `.astilba/project.json`, which records recipe and file ownership evidence. The generated workflow files do not configure GitHub repository settings for you. After you publish the repository, follow its generated `docs/repository-settings.md` checklist. ## Understand the guarantees and costs [Section titled “Understand the guarantees and costs”](#understand-the-guarantees-and-costs) | Guarantee | Cost or boundary | | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | The same Create version, recipe, and metadata produce the same planned project files. | Change the generator version, recipe version, or inputs and the plan may change. Fresh `.git` metadata is not part of the byte-for-byte file contract. | | Generation does not merge through collisions. | The destination must be absent; generated output paths must be unique and portable. | | A partial staging tree is never presented as a complete project. | Create stages beside the destination and publishes only after file, link, mode, and optional Git initialization succeed. | | Ambient Git configuration cannot inject hooks or templates. | Git initialization runs with isolated global, system, template, and `GIT_*` settings. | | Generated application code becomes yours immediately. | Create records seeded files but does not claim permission to overwrite them later. | | Recipe maintenance covers the complete project contract. | The catalog stays deliberately small; optional capabilities are added only with their development and verification paths. | Dependency installation happens after the complete project tree is published. If `pnpm install` fails, Create preserves the generated project and tells you to resolve the package-manager error and run the install again. ## Choose a path [Section titled “Choose a path”](#choose-a-path) | You want to… | Continue with | | -------------------------------------------- | ------------------------------------------------------------------ | | Assemble a command from the released catalog | [Configure a project](https://astilba.com/create/new/) | | Generate a project interactively | [Create your first project](/docs/create/quickstart/) | | Select the right maintained starting point | [Choose a recipe](/docs/create/recipes/) | | Run without prompts | [Automate project creation](/docs/create/automation/) | | Audit filesystem and failure behavior | [Deterministic generation](/docs/create/deterministic-generation/) | | Understand future migration evidence | [Project manifest](/docs/create/project-manifest/) | | Confirm what 0.3.0 supports | [Release and support](/docs/create/release-and-support/) | # Create your first project > Generate, verify, and prepare a new Astilba project for GitHub. Create a project interactively, run its complete local verification command, then apply the generated repository settings after you publish it. ## Check the prerequisites [Section titled “Check the prerequisites”](#check-the-prerequisites) Use Node.js 22.18.0 or later. Generated projects pin pnpm 11.10.0. Create initializes Git on a fresh `main` branch by default, so use a Git version that supports `git init --initial-branch` or choose `--no-git`. Dependency installation uses an installed `pnpm` only when its version is exactly 11.10.0. Otherwise, Create asks Corepack for `pnpm@11.10.0`. ## Run the questionnaire [Section titled “Run the questionnaire”](#run-the-questionnaire) From the parent directory where the new project should live, run: ```sh npm create astilba@latest ``` The questionnaire asks for: 1. one supported recipe; 2. a portable relative destination such as `my-project`; 3. a short project description; 4. the GitHub owner; 5. whether to customize the inferred project, package, and repository names; 6. whether to initialize Git; 7. whether to install dependencies; and 8. whether to create the project, change a detail, or cancel. The directory name supplies the default project name, package name, and GitHub repository name. Keep the customization prompt at **no** to accept all three together, or choose **yes** to edit them individually. Before writing, Create validates every resolved value and shows the complete project summary. You can change one detail and return to the review without restarting the questionnaire. `--yes` skips this final review action only; it does not answer missing questions. The interactive flow defaults both Git initialization and dependency installation to **yes**. Note Create requires a destination that does not exist. It will not write into an empty existing directory or merge generated files with another tree. Create reports planning, generation, and optional dependency installation as separate phases. Its completion message states whether dependencies were installed and gives the next verification step. ## Finish and verify the generated project [Section titled “Finish and verify the generated project”](#finish-and-verify-the-generated-project) If Create installed dependencies, enter the new directory and run its complete verification script: ```sh cd my-project pnpm verify ``` If you declined dependency installation or installation failed, install the generated project’s pinned dependencies first: ```sh cd my-project pnpm install --frozen-lockfile pnpm verify ``` Every recipe checks formatting and lint rules, TypeScript, tests, unused files and dependencies, and its production build. The TypeScript library recipe also validates the packed package with Publint and Are the Types Wrong. ## Review what Create owns [Section titled “Review what Create owns”](#review-what-create-owns) Open `.astilba/project.json` before your first commit. It records: * the generator and recipe versions; * managed files and their SHA-256 digests; * seeded application files that become user-owned immediately; * individually owned `package.json` fields; and * the `CLAUDE.md` symbolic link and its target. The manifest is evidence for future fail-closed migrations. Create 0.3.0 does not include an updater, and the manifest does not prevent you from changing any generated file. ## Make the first commit [Section titled “Make the first commit”](#make-the-first-commit) When Git initialization is enabled, Create produces a fresh repository on `main` without an initial commit. Review the tree, then commit it yourself: ```sh git status git add . git commit -m "chore: establish project" ``` The repository has no shared commit ancestry with Astilba Create. ## Configure GitHub after publishing [Section titled “Configure GitHub after publishing”](#configure-github-after-publishing) Push the repository to the owner and repository name you supplied, then follow `docs/repository-settings.md`. Generated files cannot turn on branch protection, merge settings, private-vulnerability reporting, a Renovate installation, or an npm publishing environment. In particular: * allow squash merging and enable automatic merging; * configure the required verification checks on `main`; * install or grant repository access to Renovate; * keep the default workflow token read-only; and * for public packages, design and configure the publication workflow, protected environment, and npm trusted publishing. Create does not generate that release path. CodeQL and dependency review are designed for public repositories. Their generated workflows skip the relevant jobs when the repository is private. ## Continue from the generated README [Section titled “Continue from the generated README”](#continue-from-the-generated-readme) The new README contains recipe-specific development commands. Use [Choose a recipe](/docs/create/recipes/) to compare the initial files and verification behavior, or [CLI reference](/docs/create/cli-reference/) when you need to rerun creation with explicit metadata. # Choose a recipe > Compare the four versioned project recipes supported by Astilba Create 0.3.0. Choose the recipe whose complete runtime and verification contract matches your project. Create does not expose its internal profiles as a mix-and-match feature selector. All four recipes are version `2` in the project manifest. ## Compare the recipes [Section titled “Compare the recipes”](#compare-the-recipes) | Recipe | Starting point | Package posture | Recipe-specific verification | | --------------------------- | ----------------------------------------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------- | | `typescript-library` | ESM TypeScript package with declarations | Public package | TypeScript build, tests, Publint, and Are the Types Wrong against the packed package | | `react-vite-spa` | Client-rendered React application built by Vite | Private application | TypeScript, Vitest, and a Vite production build | | `astro-static-site` | Statically rendered Astro site | Private application | Astro type checking, Vitest smoke test, and a static production build | | `cloudflare-worker-service` | TypeScript service for Cloudflare Workers | Private service | Generated Workers types, worker-pool tests, three TypeScript configurations, and a dry-run Wrangler deployment build | ## Shared foundation [Section titled “Shared foundation”](#shared-foundation) Every recipe includes: ```text .astilba/project.json .github/ISSUE_TEMPLATE/ .github/workflows/ .editorconfig .gitattributes .gitignore .node-version .npmrc AGENTS.md CLAUDE.md -> AGENTS.md CONTRIBUTING.md LICENSE README.md SECURITY.md docs/repository-settings.md knip.json oxfmt.config.ts oxlint.config.ts package.json pnpm-lock.yaml pnpm-workspace.yaml renovate.json tsconfig.json vitest.config.ts ``` The dependency, toolchain, action, and container references are exact pins. Renovate holds new releases for at least three days and uses conservative automerge rules. The verification workflow runs with a frozen lockfile on Node.js 22.18.0 and 24.18.0 in Create 0.3.0. ## TypeScript library [Section titled “TypeScript library”](#typescript-library) Choose `typescript-library` for a publishable ESM package: ```sh npm create astilba@latest -- my-library \ --recipe typescript-library \ --description "A useful TypeScript library." \ --github-owner example \ --package-name @example/my-library ``` The recipe adds: ```text src/index.ts tests/index.test.ts tsconfig.build.json ``` Its package export includes ESM JavaScript and declarations. `pnpm verify` builds the package and checks the packed public surface with Publint and Are the Types Wrong. ## React and Vite application [Section titled “React and Vite application”](#react-and-vite-application) Choose `react-vite-spa` for a client-rendered single-page application: ```sh npm create astilba@latest -- my-app \ --recipe react-vite-spa \ --description "A useful web application." \ --github-owner example ``` The recipe adds: ```text index.html src/app.tsx src/main.tsx src/project.json tests/project.test.ts vite.config.ts ``` The package is private. It supplies development, build, preview, test, and verification scripts, but no hosting provider or deployment automation. ## Astro static site [Section titled “Astro static site”](#astro-static-site) Choose `astro-static-site` for a statically rendered Astro site: ```sh npm create astilba@latest -- my-site \ --recipe astro-static-site \ --description "A useful static site." \ --github-owner example ``` The recipe adds: ```text astro.config.mjs src/pages/index.astro tests/smoke.test.ts ``` The package is private. `pnpm build` creates the static site, but the recipe does not choose or configure a hosting provider. ## Cloudflare Worker service [Section titled “Cloudflare Worker service”](#cloudflare-worker-service) Choose `cloudflare-worker-service` for a TypeScript service that runs on Cloudflare Workers: ```sh npm create astilba@latest -- my-worker \ --recipe cloudflare-worker-service \ --description "A useful Worker service." \ --github-owner example ``` The Worker project name must contain at most 63 lowercase letters, digits, or hyphens and cannot start or end with a hyphen. Create applies this stricter recipe rule to an explicit `--project-name` and to the name inferred from the destination. The recipe adds: ```text src/index.ts tests/index.test.ts tests/tsconfig.json tsconfig.config.json vitest.config.ts wrangler.jsonc ``` The package is private. Its build generates binding types and runs `wrangler deploy --dry-run`; only `pnpm deploy` performs a deployment. Review `wrangler.jsonc` and authenticate Wrangler before using that command. ## Know what is not a recipe option [Section titled “Know what is not a recipe option”](#know-what-is-not-a-recipe-option) Create 0.3.0 does not offer switches for Panda CSS, browser testing, monitoring, authentication, databases, framework deployment, or other optional systems. It also does not advertise arbitrary combinations of the internal project profiles. Add project-specific capabilities after generation and verify them in your repository. A capability becomes part of Create only when Astilba can maintain its complete development, CI, deployment, and verification contract. # Automate project creation > Run Astilba Create without prompts and consume its versioned JSON result safely. Supply every required input explicitly, request JSON output, and use the process exit code as the success boundary. ## Run without prompts [Section titled “Run without prompts”](#run-without-prompts) A non-interactive run requires a destination, recipe, description, and GitHub owner: ```sh npx --yes create-astilba@latest my-project \ --recipe react-vite-spa \ --description "A useful application." \ --github-owner example \ --package-name @example/my-project \ --no-install \ --json ``` `--json` never prompts, even when the terminal is interactive. It does not make missing inputs optional. Use the direct `npx` form whenever a machine will parse standard output. npm’s `create` wrapper writes its own lifecycle lines around the command, while `create-astilba` itself keeps standard output to the single JSON result. The destination name supplies these defaults: | Field | Default | | ----------------------- | -------------------------------- | | `--project-name` | normalized destination basename | | `--package-name` | normalized destination basename | | `--github-repo` | normalized destination basename | | Git initialization | enabled | | Dependency installation | disabled in non-interactive mode | Use `--install` when the workflow should run `pnpm install` after generation. Use `--no-git` when Git is intentionally unavailable. ## Plan without writing [Section titled “Plan without writing”](#plan-without-writing) Add `--dry-run` to resolve inputs and construct the complete generation plan without creating the destination, initializing Git, or installing dependencies: ```sh npx --yes create-astilba@latest my-project \ --recipe astro-static-site \ --description "A useful static site." \ --github-owner example \ --dry-run \ --json ``` The result uses `"action":"plan"` and `"installed":false`. A successful write uses `"action":"create"`. The JSON plan lists every regular-file and symbolic-link path in deterministic order. It does not return file contents or modes. A dry run validates metadata, the portable destination argument, recipe rules, generated paths, collisions, and link targets. It does not inspect the destination filesystem or exercise parent-directory preparation, Git, symbolic-link creation, or dependency installation. Run actual creation in the target environment to check that the destination is absent, any existing parent ancestry contains no symbolic links, missing parent directories can be created, and those external operations are available. ## Consume JSON output [Section titled “Consume JSON output”](#consume-json-output) Successful creation writes one JSON object to standard output: ```json { "action": "create", "destination": "/absolute/path/to/my-project", "files": [".astilba/project.json", ".editorconfig", "package.json"], "installed": false, "ok": true, "recipe": "react-vite-spa", "schemaVersion": 1, "symlinks": ["CLAUDE.md"] } ``` The actual `files` array contains every planned regular file in deterministic path order. The shortened array above only illustrates the response shape. On an ordinary error with `--json`, Create writes this shape to standard output and exits with status `1`: ```json { "destination": "/absolute/path/to/my-project", "error": { "code": "INSTALLATION_FAILED", "message": "The actionable error message.", "phase": "installation" }, "ok": false, "projectCreated": true, "schemaVersion": 1 } ``` `destination` is present when Create resolved one. `error.code` is one of `CANCELLED`, `GENERATION_FAILED`, `INSTALLATION_FAILED`, `INVALID_INPUT`, `PACKAGE_MANAGER_UNAVAILABLE`, or `UNEXPECTED_ERROR`. `CANCELLED` is the status-130 exception; the other codes use status `1`. `error.phase` is `input`, `generation`, `installation`, or `unknown`. Without `--json`, ordinary errors go to standard error with an `Error:` prefix. Cancellation and process interruption exit with status `130` in every output mode. Always check both `schemaVersion` and `ok` before reading other fields. Branch on `error.code`, `error.phase`, and `projectCreated`; treat `error.message` as human-readable context. ## Discover the released recipes [Section titled “Discover the released recipes”](#discover-the-released-recipes) Read the versioned recipe catalog when automation needs to offer or validate the released choices: ```sh npx --yes create-astilba@latest --catalog --json ``` This command does not prompt or write project files. Check `schemaVersion`, `generator.version`, and `ok` before reading `recipes`. Each recipe entry exposes its stable ID, recipe version, label, and description. The npm package includes the strict catalog schema at `schemas/catalog-v1.json`. Use the catalog as discovery metadata, not as a dependency or generated-file manifest. It deliberately excludes internal profiles, package pins, and implementation details. ## Distinguish generation from installation [Section titled “Distinguish generation from installation”](#distinguish-generation-from-installation) Create publishes the complete project tree before it runs dependency installation. This gives automation two distinct failure boundaries: 1. If generation fails, Create does not present the destination as complete. A rare failed publication rollback preserves `.astilba-create-incomplete`. 2. If installation fails, the project remains at the destination and the command exits with an error explaining how to rerun `pnpm install`. Choose `--no-install` when your workflow wants to inspect, archive, or enter the generated tree before resolving dependencies. It is also the default outside the interactive questionnaire. CLI output schema version 1 deliberately remains unchanged in Create 0.3.0. It reports whether the project was created, but it does not expose the internal distinction between an unchanged destination and an incomplete publication: both have `projectCreated: false`. When automatic recovery must be unambiguous, use `--no-install`, branch on the structured error fields, and never accept a destination that contains `.astilba-create-incomplete`. ## Pin when reproducibility requires it [Section titled “Pin when reproducibility requires it”](#pin-when-reproducibility-requires-it) `@latest` selects the npm release current at execution time. If an automation contract must stay on one generator release, invoke that exact package version: ```sh npx --yes create-astilba@0.3.0 my-project \ --recipe typescript-library \ --description "A useful library." \ --github-owner example \ --no-install \ --json ``` The generated manifest records the selected generator and recipe versions. Commit it with the project. ## Inspect catalog, help, and version as JSON [Section titled “Inspect catalog, help, and version as JSON”](#inspect-catalog-help-and-version-as-json) The informational commands support machine-readable output: ```sh npx --yes create-astilba@latest --catalog --json npx --yes create-astilba@latest --help --json npx --yes create-astilba@latest --version --json ``` Catalog returns `command`, `generator`, `ok`, `recipes`, and its own `schemaVersion`. Help returns `command`, `ok`, `schemaVersion`, and `usage`. Version returns `command`, `ok`, `schemaVersion`, and `version`. See [CLI reference](/docs/create/cli-reference/) for every option and validation rule, and [Deterministic generation](/docs/create/deterministic-generation/) for the filesystem transaction boundary. # Deterministic generation > Understand Astilba Create's planning, validation, staging, publication, and failure boundaries. Create plans and validates the complete project before it publishes a destination. It rejects ambiguous merges and portable-path hazards instead of guessing how to resolve them. ## Follow the generation sequence [Section titled “Follow the generation sequence”](#follow-the-generation-sequence) For one generator version, recipe version, and validated metadata, Create follows this sequence: 1. resolve exactly one named recipe and its required internal profiles; 2. validate project metadata and every output declaration; 3. reject file, directory, case-insensitive, and symbolic-link collisions; 4. construct the deterministic manifest and sorted project plan; 5. write files and links into a temporary sibling directory; 6. apply file and directory modes; 7. optionally initialize an isolated Git repository; 8. publish the complete top-level tree into a new destination; and 9. optionally install dependencies in the published project. `--dry-run` stops after planning and returns the file and link paths without writing anything. It validates the plan, not the target filesystem or external operations. ## Know what deterministic means [Section titled “Know what deterministic means”](#know-what-deterministic-means) The same Create version, recipe, and metadata produce the same planned regular-file bytes, path order, file modes, and symbolic-link targets. The manifest digests are therefore stable for the same plan. Fresh `.git` internals and an installed `node_modules` tree are outside that byte-for-byte contract. Git still receives a stable structural guarantee: Create starts a new repository on `main`, without template history or an initial commit. Recipe identifiers and recipe versions are permanent contract coordinates. Updating a recipe requires a new version; it does not silently redefine the recorded v2 output. ## Reject unsafe destinations [Section titled “Reject unsafe destinations”](#reject-unsafe-destinations) The command-line destination must be a normalized portable relative path. Create rejects: * absolute POSIX or Windows paths; * `.` or `..` path segments and traversal; * backslashes; * `.git` segments; * Windows device names such as `CON` or `NUL`; * trailing spaces or periods; * control or formatting characters; * non-ASCII path segments; and * characters that are not portable to Windows. During actual creation, the resolved destination must not already exist. Create makes missing parent directories one segment at a time and rejects symbolic links or non-directory entries in the existing ancestry. Dry runs skip this filesystem preparation and its checks. These rules are stricter than the host filesystem because a generated repository should remain portable when checked out elsewhere. ## Reject ambiguous output plans [Section titled “Reject ambiguous output plans”](#reject-ambiguous-output-plans) Before writing, Create rejects: * two declarations for the same path; * paths that differ only by letter case; * a regular file used as another output’s parent directory; * a profile dependency cycle or conflict; * a symbolic link that collides with another output; * a symbolic link whose target is not a planned regular file; and * any recipe output beneath the reserved `.astilba` manifest directory. Create does not have a generic “merge on collision” rule. Each named recipe must resolve to one internally consistent project. ## Publish only a complete tree [Section titled “Publish only a complete tree”](#publish-only-a-complete-tree) Create stages output in a temporary directory beside the destination. File writing, link creation, permission changes, and optional Git initialization happen there. Publication creates the destination with an incomplete marker, moves the staged top-level entries, then removes the marker only after every move succeeds. If a move fails, Create attempts to roll all moved entries back: * when rollback succeeds, it removes the marker and tries to remove the now-empty destination; * when any rollback also fails, it preserves the incomplete marker so the directory cannot be mistaken for a successful project. Create never recursively deletes a destination that another process may have changed. Cleanup failures cannot replace the publication result: an incomplete destination remains reported as incomplete, while a failure to remove an already empty staging directory does not turn a complete project into a failed generation. Human recovery output names the relevant outcome. If the incomplete marker remains, inspect the destination and either recover or remove it before running Create again. On Windows, `CLAUDE.md` requires symbolic-link permission. Enable Developer Mode or use an elevated shell. If the link cannot be created, staging fails before publication. ## Isolate Git initialization [Section titled “Isolate Git initialization”](#isolate-git-initialization) Git initialization removes ambient `GIT_*` variables and disables global and system configuration. It also supplies an empty temporary template directory. This prevents a developer’s hooks, templates, aliases, or global defaults from modifying the generated repository. Create verifies that `.git` is a real directory and that `HEAD` points to `refs/heads/main`. ## Treat installation as a separate phase [Section titled “Treat installation as a separate phase”](#treat-installation-as-a-separate-phase) Dependency installation is deliberately outside atomic generation: * Create uses an installed `pnpm` only when its version exactly matches the generated project’s pinned version. * Otherwise, it asks Corepack for that exact pnpm version before installing. * A package-manager failure leaves the generated project intact and reports that installation must be retried. This boundary avoids deleting a valid project because a registry, network, lifecycle script, or local package-manager configuration failed. ## Do not use Create as an updater [Section titled “Do not use Create as an updater”](#do-not-use-create-as-an-updater) Create 0.3.0 only creates a destination that does not exist. It does not regenerate over an existing repository, merge a newer recipe, run `doctor`, or update a default branch. The [project manifest](/docs/create/project-manifest/) records enough ownership evidence for future explicit migrations, but no migration command is shipped in this release. # Project manifest > Interpret the version, recipe, digest, field, and symbolic-link evidence in .astilba/project.json. Every generated project includes `.astilba/project.json`. Commit it with the repository: it records which Create and recipe versions produced the tree and what kind of ownership each generated output had at creation time. The manifest conforms to [the public Create v1 JSON Schema](https://astilba.com/schemas/create/v1.json). ## Read the top-level contract [Section titled “Read the top-level contract”](#read-the-top-level-contract) An abridged manifest looks like this: ```jsonc { "$schema": "https://astilba.com/schemas/create/v1.json", "features": [], "generator": { "name": "create-astilba", "version": "0.3.0" }, "ownership": { "managed": [ { "path": ".editorconfig", "sha256": "a 64-character lowercase hexadecimal digest" } ], "metadata": ".astilba/project.json", "seeded": ["README.md", "src/index.ts", "tests/index.test.ts"], "structured": [ { "fields": [ { "pointer": "/scripts/build", "sha256": "a 64-character lowercase hexadecimal digest" } ], "path": "package.json" } ], "symlinks": [ { "path": "CLAUDE.md", "target": "AGENTS.md" } ] }, "recipe": { "id": "typescript-library", "version": 2 }, "schemaVersion": 1 } ``` The example shortens the ownership arrays and replaces real digests with descriptions. Generated manifests contain the complete arrays and real SHA-256 values. ## Distinguish the versions [Section titled “Distinguish the versions”](#distinguish-the-versions) | Field | Meaning | | ------------------- | ------------------------------------------------------------------------------------ | | `schemaVersion` | Structure of the manifest itself. Version 1 is documented by the public schema. | | `generator.version` | Exact `create-astilba` package version that produced the project. | | `recipe.id` | Permanent identifier for the selected maintained recipe. | | `recipe.version` | Version of that recipe’s output contract. All recipes in Create 0.3.0 use version 2. | | `features` | Reserved feature list. It is empty in the current schema and release. | A new generator release does not necessarily imply a new manifest schema or recipe version. Each coordinate changes only when its own contract changes. ## Interpret ownership [Section titled “Interpret ownership”](#interpret-ownership) ### Managed files [Section titled “Managed files”](#managed-files) `ownership.managed` records each generator-owned configuration file with the SHA-256 digest of its UTF-8 content at generation time. The digest is evidence, not enforcement. You may edit a managed file. Future migration tooling would have to compare the recorded digest and fail closed when your content no longer matches an expected starting state. ### Metadata [Section titled “Metadata”](#metadata) `ownership.metadata` identifies `.astilba/project.json` itself. The manifest does not hash itself because that would create a recursive digest. ### Seeded files [Section titled “Seeded files”](#seeded-files) `ownership.seeded` lists application and documentation starting points that become user-owned immediately. Examples include `README.md`, source files, and tests. Future generator tooling must not assume that these files still resemble their generated form. ### Structured fields [Section titled “Structured fields”](#structured-fields) `ownership.structured` divides a JSON file into independently tracked fields. In 0.3.0, that file is `package.json`. Each `pointer` is an RFC 6901 JSON Pointer. Its digest covers the JSON serialization of that field’s original value, not the bytes of the entire file. This allows a future migration to reason about one script or dependency without claiming ownership of unrelated package metadata. ### Symbolic links [Section titled “Symbolic links”](#symbolic-links) `ownership.symlinks` records the link path and its planned target. Generated projects make `AGENTS.md` canonical and link `CLAUDE.md` to it. The target is recorded as a portable project-relative path. On Windows, creating the link requires Developer Mode or elevated privileges. ## Use the manifest today [Section titled “Use the manifest today”](#use-the-manifest-today) Create 0.3.0 does not include an update, migration, or `doctor` command. Today the manifest helps you: * identify the exact generator and recipe contract; * audit which files began as managed configuration or user-owned seeds; * verify original managed content against its digest; and * preserve future migration evidence when you commit the generated project. Do not interpret the manifest as permission for a future tool to overwrite changed files. The intended update model is explicit, authored, and fail-closed. # CLI reference > Look up Astilba Create commands, inputs, defaults, validation rules, output, and exit behavior. The supported public interface of `create-astilba` 0.3.0 is its command-line tool. ## Usage [Section titled “Usage”](#usage) ```text npm create astilba@latest npm create astilba@latest -- --recipe [options] npm create astilba@latest -- --catalog npx --yes create-astilba@latest --catalog --json ``` Use the first form for the interactive questionnaire. In the other npm forms, `--` tells npm to forward the remaining arguments to Create. Use the direct `npx` form whenever standard output must contain only the JSON object; npm’s `create` wrapper adds its own lifecycle lines. ## Recipes [Section titled “Recipes”](#recipes) | Identifier | Starting point | | --------------------------- | ------------------------------------------ | | `typescript-library` | ESM TypeScript library | | `react-vite-spa` | Client-rendered React and Vite application | | `astro-static-site` | Statically rendered Astro site | | `cloudflare-worker-service` | Cloudflare Worker service | Recipe identifiers are stable. See [Choose a recipe](/docs/create/recipes/) for generated files and verification behavior. ## Options [Section titled “Options”](#options) | Option | Short | Meaning | | ---------------------------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `--catalog` | — | List the released recipes without starting the questionnaire or writing project files. Add `--json` for versioned machine-readable output. | | `--description ` | — | Project description. Required outside the interactive questionnaire. | | `--github-owner ` | — | GitHub account that will own the repository. Required outside the interactive questionnaire. | | `--github-repo ` | — | GitHub repository name. Defaults to the normalized destination basename. | | `--package-name ` | — | npm package name. Defaults to the normalized destination basename. | | `--project-name ` | — | Project name. Defaults to the normalized destination basename. | | `--recipe ` | `-r` | Stable recipe identifier. Required outside the interactive questionnaire. | | `--git` / `--no-git` | — | Enable or disable fresh Git initialization. Defaults to enabled. | | `--install` / `--no-install` | — | Enable or disable dependency installation. Questionnaire default: enabled. Prompt-free default: disabled. | | `--dry-run` | — | Validate inputs and construct the generation plan without writing, initializing Git, or installing. JSON output returns the planned file and link paths, not their contents or modes. | | `--json` | — | Emit versioned machine-readable output. This mode never prompts. Invoke `create-astilba` directly through `npx` when a machine will parse standard output. | | `--yes` | `-y` | Skip the final interactive confirmation. It does not supply missing required inputs. | | `--version` | `-v` | Print the installed Create version. | | `--help` | `-h` | Print usage, recipes, and options. | Provide at most one destination directory. ## Prompt-free requirements [Section titled “Prompt-free requirements”](#prompt-free-requirements) Create skips the questionnaire when standard input or output is not a terminal, when you pass `--json`, or when you provide every required input explicitly. A prompt-free run requires: * one destination; * `--recipe`; * `--description`; and * `--github-owner`. Create infers project, package, and repository names from the destination when you omit their explicit options. The inference lowercases the destination basename, replaces runs of non-alphanumeric characters with `-`, trims leading and trailing hyphens, and limits the result to 63 characters. The result must include at least one letter or digit. ## Metadata validation [Section titled “Metadata validation”](#metadata-validation) | Input | Validation | | ----------------- | ---------------------------------------------------------------------------------------------------------------------- | | Description | 1–280 characters; no leading or trailing whitespace; no control or formatting characters | | GitHub owner | 1–39 characters; letters, digits, and internal hyphens; must begin and end with a letter or digit | | GitHub repository | 1–100 characters; letters, digits, dots, underscores, and hyphens; must begin and end with a letter or digit | | Package name | 1–214 characters; supported lowercase npm package name, optionally scoped | | Project name | 1–100 characters; lowercase letters, digits, dots, underscores, and hyphens; must begin and end with a letter or digit | Create validates explicit and inferred values before planning output. The `cloudflare-worker-service` recipe further restricts the project name to at most 63 lowercase letters, digits, or hyphens, with no leading or trailing hyphen. This applies whether the value comes from `--project-name` or the destination basename. ## Destination validation [Section titled “Destination validation”](#destination-validation) The destination argument must be a normalized portable relative path. It cannot contain traversal, backslashes, `.git`, Windows device names, control or formatting characters, non-ASCII path segments, trailing spaces or periods, or Windows-forbidden filename characters. For actual creation, the destination must not exist. Create makes missing parent directories, while rejecting symbolic links or non-directory entries in the existing ancestry. `--dry-run` validates the destination argument and generated output plan, but it does not inspect the destination filesystem. It therefore does not prove that the destination is absent, existing parent ancestry is safe, missing parents can be created, Git or link creation will work, or dependency installation is available. See [Deterministic generation](/docs/create/deterministic-generation/) for output collision checks, staging, rollback, and the incomplete marker. ## Human-readable output [Section titled “Human-readable output”](#human-readable-output) Interactive terminals report planning, generation, and optional dependency installation as separate phases. A successful non-TTY run without `--json` prints the same final state and next step without terminal animation: ```text Created React + Vite application at /absolute/path/to/my-project. Dependencies were not installed. Next: open /absolute/path/to/my-project, run pnpm install --frozen-lockfile, then run pnpm verify. ``` A dry run begins with `Planned`. A successful install instead reports `Dependencies installed` and directs you to `pnpm verify`. Ordinary errors print to standard error as `Error: ` and exit with status `1`. Recovery text distinguishes these filesystem outcomes: * no generated files were committed to the destination; * publication is incomplete and the `.astilba-create-incomplete` marker remains; or * the project was created, but installation or terminal reporting needs attention. Cancellation and process interruption exit with status `130` in every output mode. If cancellation arrives after publication, recovery output still says that the project exists and whether dependency installation needs to be completed. ## JSON output [Section titled “JSON output”](#json-output) `--json` writes one JSON object to standard output. Invoke the package directly to keep npm wrapper output out of that stream: ```sh npx --yes create-astilba@latest my-project \ --recipe react-vite-spa \ --description "A useful application." \ --github-owner example \ --no-install \ --json ``` Successful plan or creation: | Field | Type | Meaning | | --------------- | ---------------------- | ------------------------------------------------------ | | `action` | `"plan"` or `"create"` | Whether `--dry-run` stopped before writing | | `destination` | string | Absolute resolved destination | | `files` | string array | Every planned regular-file path in deterministic order | | `installed` | boolean | Whether dependency installation completed | | `ok` | `true` | Success discriminator | | `recipe` | string | Selected recipe identifier | | `schemaVersion` | `1` | CLI output schema version | | `symlinks` | string array | Every planned symbolic-link path | Error: | Field | Type | Meaning | | ---------------- | ---------------- | --------------------------------------------------- | | `destination` | string, optional | Absolute resolved destination when one is available | | `error.code` | string | Stable failure category | | `error.message` | string | Human-readable actionable error | | `error.phase` | string | `input`, `generation`, `installation`, or `unknown` | | `ok` | `false` | Error discriminator | | `projectCreated` | boolean | Whether a complete project was published | | `schemaVersion` | `1` | CLI output schema version | `error.code` is one of `CANCELLED`, `GENERATION_FAILED`, `INSTALLATION_FAILED`, `INVALID_INPUT`, `PACKAGE_MANAGER_UNAVAILABLE`, or `UNEXPECTED_ERROR`. `CANCELLED` maps to status `130`; the other codes map to status `1`. When `projectCreated` is `false`, inspect a resolved destination for `.astilba-create-incomplete` before treating it as unchanged. ## Recipe catalog output [Section titled “Recipe catalog output”](#recipe-catalog-output) Use the catalog when an interface or automation needs to discover released recipe IDs without duplicating a list: ```sh npx --yes create-astilba@latest --catalog --json ``` The command does not start the questionnaire or write project files. It emits one newline-terminated JSON object with: | Field | Type | Meaning | | ------------------- | ------------------ | ------------------------------------------------------------ | | `command` | `"catalog"` | Catalog result discriminator | | `generator.name` | `"create-astilba"` | Package that owns the catalog | | `generator.version` | string | Exact installed Create version | | `ok` | `true` | Success discriminator | | `recipes` | array | Stable recipe IDs, recipe versions, labels, and descriptions | | `schemaVersion` | `1` | Catalog output schema version | The catalog deliberately omits internal profiles, dependency lists, and implementation details. Its schema version is independent from the generator version and recipe versions. The schema ships in the npm package at `schemas/catalog-v1.json`. `--help --json` returns `command`, `ok`, `schemaVersion`, and `usage`. `--version --json` returns `command`, `ok`, `schemaVersion`, and `version`. ## Exit status [Section titled “Exit status”](#exit-status) | Status | Meaning | | ------ | ---------------------------------------------------------------------------------------- | | `0` | Help, version, catalog, plan, creation, and any requested install completed successfully | | `1` | Input, planning, generation, Git, dependency-installation, or terminal-reporting error | | `130` | The operation was cancelled or interrupted | For a complete automated example, see [Automate project creation](/docs/create/automation/). # Release and support > Check the exact package, runtime, recipe, platform, and verification surface supported by Astilba Create 0.3.0. `create-astilba` 0.3.0 is published on npm from the public [`astilbahq/create`](https://github.com/astilbahq/create) repository. The source release is tagged [`v0.3.0`](https://github.com/astilbahq/create/tree/v0.3.0). ## Supported public surface [Section titled “Supported public surface”](#supported-public-surface) | Surface | 0.3.0 status | | --------------------------------------- | ---------------------------------------------------------------------- | | Interactive CLI | Recipe-first questionnaire with validated review and per-field editing | | Non-interactive CLI | Released | | Versioned recipe catalog | Schema version 1 through `--catalog --json` | | Versioned JSON output | Schema version 1 | | `--dry-run` planning | Released | | `typescript-library` | Recipe version 2 | | `react-vite-spa` | Recipe version 2 | | `astro-static-site` | Recipe version 2 | | `cloudflare-worker-service` | Recipe version 2 | | Project manifest | Schema version 1 | | Programmatic TypeScript API | Not exported by the npm package | | Update, migration, or `doctor` commands | Not shipped | | Arbitrary recipe composition | Not supported | The npm export map exposes package metadata only. Internal generator modules in the source repository are implementation details and are not a supported import path. Create 0.3.0 adds a versioned, non-interactive recipe catalog and its strict JSON Schema. All released recipe IDs, recipe versions, lockfiles, and output fingerprints remain unchanged from 0.2.0. Planning, generation, and optional dependency installation remain separate interactive phases. Human success and recovery output distinguishes a failure that committed no generated files, an incomplete publication with its marker, and a complete project whose installation or terminal reporting needs attention. The non-interactive creation flags, exit codes, and creation JSON schema version remain compatible with 0.1.2. ## Runtime requirements [Section titled “Runtime requirements”](#runtime-requirements) | Requirement | Supported value | | --------------------------------- | ------------------------ | | Create CLI runtime | Node.js 22.18.0 or later | | Generated project package manager | pnpm 11.10.0 | You invoke Create through npm, but generated projects use pnpm. With `--install`, Create uses the `pnpm` executable only when it reports version 11.10.0. Otherwise, Create asks Corepack for `pnpm@11.10.0`. Generated verification runs on the minimum Node.js release and the current supported major recorded by Create. In 0.3.0, those lanes are Node.js 22.18.0 and 24.18.0. ## Platform boundary [Section titled “Platform boundary”](#platform-boundary) Generated paths are restricted to a portable ASCII subset and checked for Windows device names and case-insensitive collisions. Every recipe creates `CLAUDE.md` as a symbolic link to `AGENTS.md`. Windows therefore requires Developer Mode or an elevated shell. If the filesystem refuses the link, Create stops in staging and does not publish a partial project. Before cloning a generated repository on Windows, enable symbolic-link support and configure Git to preserve symbolic links. ## Release evidence [Section titled “Release evidence”](#release-evidence) The Create repository verifies more than its own unit tests: * `pnpm verify` runs Ultracite, TypeScript, Vitest, Knip, and the package build; * `pnpm test:consumers` generates, installs, and runs `pnpm verify` in every recipe as an independent project; * `pnpm test:package` packs the actual npm tarball, verifies its contents and executable, installs it in a clean temporary consumer, generates all four recipes with JSON output, checks each manifest’s schema URL and recipe identity, and verifies each project; * the on-demand published-package acceptance workflow installs an exact npm version on Linux, macOS, and Windows, follows the public CLI path, checks its catalog and side-effect-free dry run, verifies the generated manifest and agent-instruction link, and runs the generated project’s own verification; * CI repeats recipe consumers on the supported Node.js lanes; * a Windows packed-CLI smoke test exercises symbolic-link creation; * Actionlint and Zizmor audit the emitted GitHub workflows; and * OSV-Scanner, CodeQL, and dependency review run against the Create source repository. The packed npm artifact contains the license, README, package metadata, compiled `dist` files, the public project-manifest and catalog schemas, and the recipe contract metadata and canonical lockfiles needed to verify generation. ## Publication evidence [Section titled “Publication evidence”](#publication-evidence) The [`v0.3.0` GitHub Release](https://github.com/astilbahq/create/releases/tag/v0.3.0) ran the checked-in release workflow. It verified that the release tag pointed to `main` and matched the package version, rebuilt and checked the package, and published through a protected GitHub environment with npm trusted publishing. The [`create-astilba@0.3.0` npm record](https://www.npmjs.com/package/create-astilba/v/0.3.0) includes a provenance attestation that identifies the public source repository and GitHub Actions release workflow. After publication, the [`create-astilba@0.3.0` acceptance run](https://github.com/astilbahq/create/actions/runs/30172510973) passed its Linux Astro, macOS TypeScript library, and Windows Cloudflare Workers journeys against the exact public package. These representative cross-platform journeys complement the complete recipe matrix in the ordinary source and packed-package tests; they do not claim every recipe-and-platform combination. ## Responsibility after generation [Section titled “Responsibility after generation”](#responsibility-after-generation) Create verifies the generated repository files, but it cannot apply hosted settings. You must follow the generated `docs/repository-settings.md` after pushing to GitHub. You also own product-specific work after generation: * choose and configure deployment infrastructure; * provision secrets and external services; * replace seeded example code; * decide repository visibility and publishing policy; and * keep project-specific capabilities tested. The Worker recipe includes Wrangler development, type-generation, dry-run build, and deployment commands. The Astro and React recipes deliberately do not choose a hosting provider. The library recipe supplies package checks but does not publish for you. ## Deliberate omissions [Section titled “Deliberate omissions”](#deliberate-omissions) Create 0.3.0 does not include optional Panda CSS, monitoring, browser testing, authentication, databases, or general deployment automation. These are not hidden flags or parked public recipes. Future repair and update tooling is intended to use explicit authored migrations and the manifest’s ownership evidence. It will not regenerate over an existing repository or silently mutate a default branch. Report generator defects or recipe regressions in the public [Create issue tracker](https://github.com/astilbahq/create/issues). # Overview > Understand Astilba Env's contract model, generated boundaries, supported runtimes, and responsibility boundary. Astilba Env turns one portable TypeScript declaration into generated configuration interfaces for the artifacts that consume them. It keeps browser and server projections physically separate, distinguishes build, deployment, and request values, and validates values at the lifecycle where they become available. The 0.3 release is a public alpha. Use it when explicit configuration boundaries are worth adopting before the API reaches stability. ## Decide whether Env fits [Section titled “Decide whether Env fits”](#decide-whether-env-fits) | You want to… | Fit | | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Promote one built artifact through several deployments | Declare deployment values and resolve them from each deployment’s source object. | | Keep private names and bindings out of browser bundles | Browser consumers receive generated public projections only. | | Validate build, deployment, and request values separately | Each entry declares its lifecycle; each generated target resolves one lifecycle. | | Run without an Astilba service | Generation, checking, and planning are local; the application owns value storage and delivery. | | Compare declared names with an application-owned provider list | Export a value-free target inventory, convert the provider’s names to Env’s strict observed format, and check open or closed ownership without giving Env provider credentials. | | Validate Worker bindings without a Node.js compatibility layer | Env admits a narrow generated deployment-target path for Cloudflare Workers. | | Inject arbitrary configuration through inline JavaScript | Not supported. Browser deployment and request values use inert, same-origin JSON. | | Import one mutable environment object everywhere | Not supported. Generated modules create explicit artifact boundaries. | | Provision secrets or platform bindings | Not supported. Env validates application-owned sources; it does not operate providers. | ## Model four decisions [Section titled “Model four decisions”](#model-four-decisions) An Env declaration records four independent decisions: 1. an entry’s visibility is `public` or `private`; 2. its lifecycle is `build`, `deployment`, or `request`; 3. its codec defines the accepted input and typed output; and 4. a consumer selects which entries one artifact may know. A target then maps one consumer and one complete lifecycle to names in an application-owned source object. ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.application", entries: { apiOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), }, consumers: { browser: env.browser(["apiOrigin"]), server: env.server(["databaseUrl"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", }), }, }); ``` The declaration does not read either value. ## Generate artifact-specific modules [Section titled “Generate artifact-specific modules”](#generate-artifact-specific-modules) Run generation on a supported Node.js release and check drift in CI: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` The declaration above produces separate interfaces: ```text .astilba/env/browser/browser.deployment.ts .astilba/env/browserDeployment.server.ts .astilba/env/serverDeployment.server.ts ``` Generated target modules export typed `check(source)` and `load(source)` functions. Generated browser build modules may contain frozen public build values; deployment and request projection modules contain the public decoder and compatibility identity for one consumer and lifecycle, but no values, private entry names, private bindings, or complete contract metadata. Read [Lifecycles and projections](/docs/env/lifecycles-and-projections/) for the generated file model. ## Choose the runtime path [Section titled “Choose the runtime path”](#choose-the-runtime-path) | Path | Lifecycle and generated import | Example | | ----------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------ | | Node server | Generate on Node.js, then load a generated server target for the lifecycle your server owns. | [Node server](https://github.com/astilbahq/env/tree/main/examples/node-service) | | Cloudflare Worker deployment target | Run a generated deployment-lifecycle target through `@astilba/env/runtime` with built-in codecs. Authoring and generation stay on Node.js. | [Cloudflare Worker](https://github.com/astilbahq/env/tree/main/examples/cloudflare-worker) | | Next static shell | Import public build values from the generated browser `.build.ts` module. Bootstrap deployment values through an application-owned route without making the page dynamic. | [Next static shell](https://github.com/astilbahq/env/tree/main/examples/next-static-shell) | | Vite browser shell | Import public build values from the generated browser `.build.ts` module, or bootstrap deployment values from an application-owned endpoint and generated browser projection. | [Vite browser shell](https://github.com/astilbahq/env/tree/main/examples/vite) | The Worker path is limited to generated deployment-lifecycle targets with built-in codecs. It does not establish support for declaration authoring, generation, request targets, opaque schemas, or other package exports in Workers. Framework pages explain the application-owned wiring: [Vite](/docs/env/vite/) adds a private-module browser-graph boundary, [Next.js](/docs/env/nextjs/) keeps static shells static, and [Deliver browser configuration](/docs/env/browser-delivery/) defines both same-origin endpoint delivery and framework-transported inert JSON. Check [Release and support](/docs/env/release-and-support/) before choosing a runtime. Evidence for one package export does not make every Env export portable to that runtime. ## Compose Env with your stack [Section titled “Compose Env with your stack”](#compose-env-with-your-stack) Env can replace application-specific configuration parsing, required-value helpers, public and private naming conventions, and projection glue. It complements the systems that store, deliver, inventory, or describe the source values. | Concern | Existing authority | Env’s role | | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Secret storage and injection | A secret manager, CI system, or deployment platform owns the values and delivers them to the application. | A generated target reads only the selected values inside your application process after delivery. Astilba receives no values; Env provides no secret storage, rotation, or provisioning. | | Worker binding types | `wrangler types` describes the complete Worker binding interface. | A generated deployment target decodes mapped string settings and secrets. D1, KV, R2, and service bindings are capabilities, so they stay outside the configuration result and remain available through the Wrangler-generated interface. | | Live deployment inventory and preflight | Deployment tooling or application-owned assertions query the platform and decide whether a deployment may proceed. | Env validates the explicit source object at the declared lifecycle. It does not query a provider or prove what is present in a remote deployment. | | Declared-name drift | Application-owned tooling converts a provider or platform name list into Env’s strict observed format. | `inventory export` publishes the declared name contract; `inventory check` compares names and required presence. Env never receives values or provider credentials. | | Server configuration parsing | Hand-written `requiredEnv()` helpers or schema wrappers turn ambient strings into application values. | Generated `check(source)` and `load(source)` operations can replace that parsing while sharing one contract across selected artifacts. | | Browser configuration delivery | An application-owned route serves inert, same-origin JSON, or a framework safely transports the serialized envelope as inert data. The application owns the HTTP and rendering policy. | Env generates the public projection, compatibility identity, and envelope validation; it does not host a route or choose the transport. | Use Env when one contract replaces repeated parsing or crosses artifact and lifecycle boundaries. Keep your existing parser when one server already has a reliable parser, you have no browser configuration surface, and you do not need to promote the same artifact through several deployments. Continue with [Configure a Node application](/docs/env/quickstart/) for the smallest working setup, or [Check name inventory drift](/docs/env/inventory-and-drift/) when duplicated name lists are the problem. # Configure a Node application > Install Astilba Env, declare deployment configuration, generate typed modules, and fail startup safely. In this guide, you will replace direct `process.env` reads with one typed deployment target. Env will validate the values without logging them and generate a module owned by your application. ## Check the requirements [Section titled “Check the requirements”](#check-the-requirements) Env 0.3.0 requires a supported Node.js release: * Node.js 22.14.0 or later within Node 22; * Node.js 24.x; or * Node.js 26.x. The default `astilba.env.ts` filename requires an ESM package with `"type": "module"`. A lowercase `.mts` configuration file works without changing the package type. Install the exact public-alpha release: ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` ## Declare the configuration [Section titled “Declare the configuration”](#declare-the-configuration) Create `astilba.env.ts` at the package root: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.api", entries: { apiOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), port: env.private.deployment.integer({ minimum: 1, maximum: 65_535, }), }, consumers: { server: env.server(["apiOrigin", "databaseUrl", "port"]), }, targets: { serverDeployment: env.process("server", { apiOrigin: "API_ORIGIN", databaseUrl: "DATABASE_URL", port: "PORT", }), }, }); ``` The declaration does not read the three environment variables. It records: * which logical entries exist; * whether each entry is public or private; * when each value becomes available; * which artifact may consume it; and * how the target maps logical entries to source names. `public` means the value may be exposed to a browser consumer. It does not mean that Env publishes the value. `private` entries cannot enter a browser projection. ## Generate the target module [Section titled “Generate the target module”](#generate-the-target-module) Generate the application-owned output: ```sh pnpm exec astilba-env generate ``` The target in this guide produces `.astilba/env/serverDeployment.server.ts`. Import its generated `load` function during startup: ```ts import { load } from "./.astilba/env/serverDeployment.server"; const configuration = load(process.env); startServer({ databaseUrl: configuration.databaseUrl, origin: configuration.apiOrigin, port: configuration.port, }); ``` `load` returns a frozen, typed configuration when every value is valid. It throws `EnvironmentConfigurationError` otherwise. The error carries redacted diagnostics; it does not include the rejected values. Use `check` when your application needs to choose its own failure response: ```ts import { check } from "./.astilba/env/serverDeployment.server"; const result = check(process.env); if (!result.ok) { console.error("Configuration is invalid.", result.diagnostics); process.exitCode = 1; } else { startServer(result.value); } ``` The diagnostic entries identify stable error codes and logical entry names. Do not add the original source record to logs. ## Check values without starting the application [Section titled “Check values without starting the application”](#check-values-without-starting-the-application) The CLI can validate the target against the current process environment: ```sh API_ORIGIN=https://api.example.com \ DATABASE_URL=postgres://example \ PORT=3000 \ pnpm exec astilba-env check --target serverDeployment ``` A valid target exits with status `0`. Missing or invalid values exit with status `1`. The command reports whether the target is valid, never the values it observed. ## Keep generated output current [Section titled “Keep generated output current”](#keep-generated-output-current) Add repeatable scripts: ```json { "scripts": { "env:generate": "astilba-env generate", "env:check": "astilba-env generate --check" } } ``` Run `pnpm env:generate` after changing the declaration. Commit `astilba.env.ts` and `.astilba/env/`, then require `pnpm env:check` in CI. `generate --check` performs no writes. It exits nonzero when a generated file is missing, changed, or unexpected. Keeping `snapshot.json` in Git also lets [`astilba-env plan --base`](/docs/env/cli-reference/#plan) compare a proposed contract with a committed revision without executing the historical configuration file. Next, read [Lifecycles and projections](/docs/env/lifecycles-and-projections/) before adding build, browser, or request configuration. # Node.js > Author Env declarations, generate project-owned modules, and resolve typed configuration in supported Node.js releases. Node.js is Env’s authoring and tooling runtime. Use it to execute `astilba.env.ts`, generate project-owned modules, check generated drift, validate process targets through the CLI, and plan declaration changes. Generated server targets also run in Node.js. They read only the source object you pass to `check` or `load`; Env does not add a global environment loader. ## Check the supported releases [Section titled “Check the supported releases”](#check-the-supported-releases) Env 0.3.0 supports: * Node.js 22.14.0 or later within Node 22; * Node.js 24 within Node 24; and * Node.js 26 within Node 26. Use an ESM package with `"type": "module"` for the default `astilba.env.ts` configuration filename. A lowercase `.mts` configuration file is also supported. ## Keep authoring separate from resolution [Section titled “Keep authoring separate from resolution”](#keep-authoring-separate-from-resolution) The declaration describes configuration without reading current values: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.api", entries: { databaseUrl: env.private.deployment.secret(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), }, consumers: { server: env.server(["databaseUrl", "port"]), }, targets: { serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", port: "PORT", }), }, }); ``` Run generation on a supported Node.js release: ```sh pnpm exec astilba-env generate ``` The generated `.astilba/env/serverDeployment.server.ts` module imports the narrow `@astilba/env/runtime` surface. Your application chooses when to pass `process.env`. ## Choose `check` or `load` [Section titled “Choose check or load”](#choose-check-or-load) Use `check` when application code owns the failure path: ```ts import { check } from "./.astilba/env/serverDeployment.server"; const result = check(process.env); if (!result.ok) { console.error("Configuration is invalid.", result.diagnostics); process.exitCode = 1; } else { startServer(result.value); } ``` Use `load` when invalid configuration should throw: ```ts import { load } from "./.astilba/env/serverDeployment.server"; const configuration = load(process.env); startServer(configuration); ``` Both operations return owned, frozen values. Diagnostics contain stable codes and logical identities where appropriate; they do not contain rejected values, fragments, lengths, or hashes. ## Audit the runtime export [Section titled “Audit the runtime export”](#audit-the-runtime-export) Generated server targets import `@astilba/env/runtime` for you. Application code should normally import the generated target’s typed `check` or `load` function rather than construct a target definition itself. The runtime export is public so generated modules can use one stable boundary and advanced consumers can audit every dependency: | Export | Purpose | | ------------------------------- | -------------------------------------------------------------------------------------------------------------- | | `checkProcessTarget` | Validate and resolve a generated target with built-in codecs without throwing for configuration failures. | | `loadProcessTarget` | Resolve a generated target with built-in codecs or throw `EnvironmentConfigurationError`. | | `checkProcessTargetWithSchemas` | Resolve a Node.js target with application-owned Standard Schema validators and return a Promise of the result. | | `loadProcessTargetWithSchemas` | Resolve a Node.js target with Standard Schema validators or reject with `EnvironmentConfigurationError`. | | `EnvironmentConfigurationError` | Identify a failed `load*` operation and expose its value-free `diagnostics`. Direct construction is rejected. | | `ProcessSource` | Describe the application-owned object from which a generated target reads raw values. | | `ProcessTargetDefinition` | Describe the generated target definition consumed by the runtime. | | `ProcessTargetSchemas` | Map an opaque entry name to its application-owned Standard Schema validator. | | `StandardSchemaV1` | Describe the minimal Standard Schema v1 contract accepted for private opaque entries. | | `StandardSchemaResult` | Describe the immediate success or failure result returned by that validator contract. | The `*WithSchemas` operations return Promises, but validators must settle synchronously. A Promise or thenable from a validator produces `ENV_VALIDATOR_ASYNC_UNSUPPORTED`. The workerd conditional export does not execute opaque validators and admits deployment targets only. ## Resolve request values explicitly [Section titled “Resolve request values explicitly”](#resolve-request-values-explicitly) Node.js targets may also resolve request-lifecycle values from an application-owned object: ```ts import { load } from "./.astilba/env/serverRequest.server"; export const handleRequest = (request: Request): Response => { const configuration = load({ TENANT_ID: readTrustedTenant(request), }); return respondForTenant(configuration.tenantId); }; ``` Do not retain request configuration in process-global state or an unpartitioned cache. Env validates the supplied object; your application still authenticates the request and owns the source lifetime. ## Keep the CLI in Node.js [Section titled “Keep the CLI in Node.js”](#keep-the-cli-in-nodejs) The declaration builders, generator, and CLI are Node.js tools even when a generated target runs somewhere else. For example, author and generate a [Cloudflare Workers target](/docs/env/cloudflare-workers/) in Node.js, then import only its generated module in the Worker. Read [Configure a Node application](/docs/env/quickstart/) for the complete first setup and [CLI reference](/docs/env/cli-reference/) for command behavior. # Browser > Load and validate public browser configuration through Env's isolated browser runtime. The `@astilba/env/browser` export validates public configuration before browser application code uses it. It accepts only generated public projections; declaration builders, private targets, provider bindings, and complete contract metadata stay outside the browser graph. Browser delivery is application-owned. Env validates either a same-origin JSON response or inert JSON that your framework has already transported. The browser export contains the runtime values below: | Export | Purpose | | ------------------------- | -------------------------------------------------------------------------------------------------- | | `loadBrowserBootstrap` | Fetch and validate a same-origin JSON envelope. | | `parseBrowserBootstrap` | Validate an already transported serialized JSON envelope without fetching. | | `startBrowserApplication` | Validate with `loadBrowserBootstrap`, then import and start the application module. | | `BootstrapFailure` | Identify an expected bootstrap failure with `instanceof` and read its stable `code`. | | `BOOTSTRAP_PROTOCOL` | Build an application-owned response with the current `astilba.env.bootstrap/v1` protocol identity. | | `MAXIMUM_BOOTSTRAP_BYTES` | Read the current 65,536-byte response limit without duplicating it in application code. | It also exports the supporting `BootstrapFailureCode`, `BrowserApplicationModule`, `BrowserAudience`, `BrowserProjection`, `BrowserValues`, `LoadBootstrapOptions`, `ParseBootstrapOptions`, `StartBrowserApplicationOptions`, and `ValidatedBootstrap` types. Generated projections provide `BrowserProjection`; application code should import a generated projection rather than construct one. ## Import only the browser surface [Section titled “Import only the browser surface”](#import-only-the-browser-surface) Generate a public browser consumer and import its projection: ```ts import { loadBrowserBootstrap } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; const bootstrap = await loadBrowserBootstrap({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, projection, requestBaseUrl: window.location.href, }); renderApplication(bootstrap.values); ``` `loadBrowserBootstrap` requests with `cache: "no-store"` and `redirect: "error"`. It checks the same-origin request and final response, JSON content type and size, envelope identity, expected audience, generated projection digest, exact fields, and typed values. The returned values are copied into frozen, owned data. ## Parse framework-transported JSON [Section titled “Parse framework-transported JSON”](#parse-framework-transported-json) Use `parseBrowserBootstrap` when a framework has already delivered the exact envelope as inert data: ```ts import { BootstrapFailure, parseBrowserBootstrap, } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; const source = document.querySelector("#env")?.textContent ?? ""; try { const bootstrap = parseBrowserBootstrap({ expectedAudience: { origin: window.location.origin }, projection, source, }); renderApplication(bootstrap.values); } catch (error) { const code = error instanceof BootstrapFailure ? error.code : "BOOTSTRAP_UNEXPECTED"; renderConfigurationFailure(code); } ``` Pass the serialized JSON text, not the result of `JSON.parse`. Let the framework serialize and escape the envelope for an inert data container; do not interpolate unescaped JSON into HTML yourself. Do not replace the data with an executable script assignment or a mutable global. Missing or malformed transported JSON follows the same configuration-failure boundary as an invalid bootstrap. ## Delay the application import [Section titled “Delay the application import”](#delay-the-application-import) Use `startBrowserApplication` when the main application module must not enter the active graph before configuration validates: ```ts import { startBrowserApplication } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; await startBrowserApplication({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, importApplication: () => import("./application"), projection, requestBaseUrl: window.location.href, }); ``` The imported module exports `start(values, audience)`. Env validates first, imports second, and calls `start` last. ## Handle failures without fallback [Section titled “Handle failures without fallback”](#handle-failures-without-fallback) Browser operations throw `BootstrapFailure` with a stable code: ```ts import { BootstrapFailure, loadBrowserBootstrap, } from "@astilba/env/browser"; try { const bootstrap = await loadBrowserBootstrap(options); renderApplication(bootstrap.values); } catch (error) { const code = error instanceof BootstrapFailure ? error.code : "BOOTSTRAP_UNEXPECTED"; renderConfigurationFailure(code); } ``` Do not continue with ambient, baked, or previously cached values after validation fails. A retry should perform a new validation without weakening the expected audience or projection identity. The public `BootstrapFailureCode` union is grouped by the boundary that refused the bootstrap: * request and response: `BOOTSTRAP_REQUEST_ORIGIN_MISMATCH`, `BOOTSTRAP_FETCH_FAILED`, `BOOTSTRAP_REDIRECTED`, `BOOTSTRAP_FINAL_ORIGIN_MISMATCH`, `BOOTSTRAP_HTTP_STATUS_INVALID`, `BOOTSTRAP_INVALID_MIME`, `BOOTSTRAP_BODY_READ_FAILED`, `BOOTSTRAP_BODY_TOO_LARGE`, and `BOOTSTRAP_INVALID_UTF8`; * JSON input: `BOOTSTRAP_INVALID_JSON`, `BOOTSTRAP_DUPLICATE_KEY`, `BOOTSTRAP_JSON_TOO_DEEP`, `BOOTSTRAP_JSON_TOO_MANY_KEYS`, and `BOOTSTRAP_NON_PORTABLE_JSON`; * envelope identity: `BOOTSTRAP_UNKNOWN_FIELD`, `BOOTSTRAP_FIELD_MISSING`, `BOOTSTRAP_FIELD_INVALID`, `BOOTSTRAP_PROTOCOL_UNSUPPORTED`, `BOOTSTRAP_CONTRACT_MISMATCH`, `BOOTSTRAP_LIFECYCLE_MISMATCH`, `BOOTSTRAP_PROJECTION_MISMATCH`, and `BOOTSTRAP_AUDIENCE_MISMATCH`; and * generated projection and values: `BOOTSTRAP_PROJECTION_INVALID`, `BOOTSTRAP_GENERATED_FORMAT_UNSUPPORTED`, `BOOTSTRAP_VALUE_MISSING`, and `BOOTSTRAP_VALUE_INVALID`. Treat the codes as diagnostics and telemetry identities, not user-facing copy. An unexpected non-Env exception has no Env failure code; map it to an application-owned fallback state. ## Keep private modules out of the graph [Section titled “Keep private modules out of the graph”](#keep-private-modules-out-of-the-graph) Browser code may import: * `@astilba/env/browser`; * generated `browser/*.build.ts` modules; and * generated `browser/*.deployment.ts` or `browser/*.request.ts` projections; and * generated `consumers/*.public.json` evidence when an application explicitly needs the value-free public manifest. It must not import the root package, `@astilba/env/runtime`, the Env configuration file, generated `*.server.ts` targets, or complete generated metadata. Use the [Vite integration](/docs/env/vite/) where Vite builds the browser graph. Other build tools need an equivalent application-owned boundary. Continue with [Deliver browser configuration](/docs/env/browser-delivery/) to build the endpoint and exact envelope. # Cloudflare Workers > Generate a deployment target on Node.js and validate Wrangler bindings directly inside a Cloudflare Worker. Env 0.3.0 admits a narrow Cloudflare Workers runtime path: a generated server target for the `deployment` lifecycle may call `check(env)` or `load(env)` inside a Worker handler when every selected entry uses a first-party Env codec. Authoring, generation, CLI checks, and planning still run on a [supported Node.js release](/docs/env/nodejs/). The Worker imports the generated target and the `@astilba/env/runtime` dependency behind it; it does not import the root package, browser runtime, CLI, or Vite integration. ## Declare one deployment target [Section titled “Declare one deployment target”](#declare-one-deployment-target) Declare Worker configuration on Node.js: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.worker", entries: { apiOrigin: env.public.deployment.origin(), signingKey: env.private.deployment.secret(), }, consumers: { worker: env.server(["apiOrigin", "signingKey"]), }, targets: { workerDeployment: env.process("worker", { apiOrigin: "API_ORIGIN", signingKey: "SIGNING_KEY", }), }, }); ``` Generate and commit the application-owned output: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` This creates `.astilba/env/workerDeployment.server.ts`. Generation does not read Worker bindings for a deployment target and does not contact Cloudflare. ## Declare Wrangler bindings [Section titled “Declare Wrangler bindings”](#declare-wrangler-bindings) Keep non-secret values in `vars`, and declare required secret names with `secrets.required`: ```jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "example-worker-staging", "main": "src/index.ts", "compatibility_date": "2026-07-29", "vars": { "API_ORIGIN": "https://staging-api.example.com" }, "secrets": { "required": ["SIGNING_KEY"] }, "kv_namespaces": [ { "binding": "CACHE", "id": "" } ] } ``` Configure the secret value through Cloudflare, outside source control. `secrets.required` records the required name; it does not contain the secret. The same built Worker artifact can use another binding set: ```jsonc { "$schema": "node_modules/wrangler/config-schema.json", "name": "example-worker-production", "main": "src/index.ts", "compatibility_date": "2026-07-29", "vars": { "API_ORIGIN": "https://api.example.com" }, "secrets": { "required": ["SIGNING_KEY"] }, "kv_namespaces": [ { "binding": "CACHE", "id": "" } ] } ``` Env validates values at runtime. Its provider-neutral inventory command can compare the declared string names with an application-supplied name list; it does not call Cloudflare, inspect binding kinds, or decide when a changed set should redeploy. Choose the latest compatibility date supported by your installed Wrangler release. Env does not impose its own date floor. The [0.3.0 release matrix](https://github.com/astilbahq/env/actions/runs/31482997555) exercises the exact packed archive with stock Wrangler 4.115.0 and its bundled workerd at compatibility date `2026-07-29`; it does not replace Wrangler’s transitive runtime. ## Generate Cloudflare’s binding types [Section titled “Generate Cloudflare’s binding types”](#generate-cloudflares-binding-types) Generate the Worker `Env` interface from Wrangler configuration: ```sh pnpm exec wrangler types pnpm exec wrangler types --check ``` [`wrangler types`](https://developers.cloudflare.com/workers/languages/typescript/#generate-types) derives binding types from your configuration. Use `--check` in CI, so the generated interface cannot drift. The generated target accepts that interface without requiring a string index signature: ```ts import { check } from "../.astilba/env/workerDeployment.server"; export default { async fetch(request: Request, env: Env): Promise { const result = check(env); if (!result.ok) { return Response.json( { diagnostics: result.diagnostics, ok: false }, { status: 500 } ); } const cached = await env.CACHE.get(request.url); return Response.json({ apiOrigin: result.value.apiOrigin, cached: cached !== null, }); }, } satisfies ExportedHandler; ``` `check(env)` reads only `API_ORIGIN` and `SIGNING_KEY`, then returns an exact owned result. The unrelated `CACHE` capability binding remains available to application code and does not enter the configuration result. Use `load(env)` instead when invalid deployment configuration should throw. Do not log the original `env` object on failure. ## Pass string bindings without coercion [Section titled “Pass string bindings without coercion”](#pass-string-bindings-without-coercion) Every source binding selected by the generated target must be a string or `undefined`. A missing optional binding may be `undefined`; a present binding is decoded from its exact string value. Cloudflare also allows JSON values in `vars` and objects for capability bindings. Env does not stringify or coerce those values. If a selected source name resolves to a JSON value, KV namespace, D1 database, service binding, or another capability object, `check` returns a redacted invalid-value diagnostic and `load` throws. Unselected bindings are different: Env does not read or reject them. Keep `CACHE` and other capabilities outside the target mapping, then use them directly through the Wrangler-generated `Env` interface. ## Keep the compatibility surface narrow [Section titled “Keep the compatibility surface narrow”](#keep-the-compatibility-surface-narrow) Env’s generated runtime does not need the [`nodejs_compat` compatibility flag](https://developers.cloudflare.com/workers/runtime-apis/nodejs/) for this path. Add that flag only when other application dependencies require Node.js APIs. The Env 0.3.0 Workers claim includes: * generated server targets for the `deployment` lifecycle; * first-party Env codecs; and * direct `check(env)` or `load(env)` calls inside the handler. It does not include: * request-lifecycle generated targets; * `opaque` entries or caller-provided Standard Schema validators; * the root `@astilba/env` authoring export in workerd; * `@astilba/env/browser` or `@astilba/env/vite` in workerd; or * the Env CLI in workerd. ## Know what Env does not operate [Section titled “Know what Env does not operate”](#know-what-env-does-not-operate) Env does not provide: * Worker, route, or binding provisioning; * secret storage or rotation; * a live Cloudflare binding query or Wrangler-specific parser; * a Cloudflare provider API client; or * automatic deployment or redeployment planning. Wrangler and Cloudflare own those operations. If duplicated string-binding names are the problem, convert a Wrangler-produced name list into Env’s provider-neutral observed format and follow [Check name inventory drift](/docs/env/inventory-and-drift/). See the official [Wrangler configuration reference](https://developers.cloudflare.com/workers/wrangler/configuration/) for binding configuration and required-secret behavior. # Vite > Keep private Env declarations, targets, and metadata out of Vite browser graphs. The `@astilba/env/vite` integration rejects private Env surfaces when Vite builds browser code. It is a build-time import boundary; it does not generate configuration, create an endpoint, or load browser values. Env 0.3.0 supports Vite 8.1.5 or later within Vite 8. ## Add the boundary plugin [Section titled “Add the boundary plugin”](#add-the-boundary-plugin) Add the plugin to every Vite configuration that can produce a browser graph: ```ts import { astilbaEnvBrowserBoundary } from "@astilba/env/vite"; import { defineConfig } from "vite"; export default defineConfig({ plugins: [astilbaEnvBrowserBoundary()], }); ``` Keep the plugin active in development and production builds. A development-only boundary can allow an import that fails or leaks later in CI. ## Understand the allowed graph [Section titled “Understand the allowed graph”](#understand-the-allowed-graph) Browser modules may import: * `@astilba/env/browser`; * generated `browser/*.build.ts` values; and * generated `browser/*.deployment.ts` or `browser/*.request.ts` projections; and * generated `consumers/*.public.json` evidence. The plugin rejects imports of: * the root `@astilba/env` declaration package; * `@astilba/env/runtime` and `@astilba/env/vite`; * `astilba.env.ts` or `.mts`; * generated `*.server.ts` modules; * `contract.json`, `snapshot.json`, and generated `consumers/*.server.json` metadata; and * package-owned files outside the public browser runtime. The plugin emits the stable `ENV_BROWSER_PRIVATE_IMPORT` failure code. Use Vite’s import trace to locate the refused edge, then move it behind a server boundary instead of adding an exception. ## Choose build-fixed or deployment-bootstrap values [Section titled “Choose build-fixed or deployment-bootstrap values”](#choose-build-fixed-or-deployment-bootstrap-values) Use a public `build` entry and its generated browser `.build.ts` module when Vite may fix the value at build time. When one built browser shell must receive deployment values, expose the generated public projection through an application-owned endpoint and bootstrap it in the browser. The [Vite browser shell example](https://github.com/astilbahq/env/tree/main/examples/vite) is an executable adoption fixture that demonstrates both choices. Its `Host`-derived audience fallback is restricted to exact local `localhost` and loopback forms, and its combined server target deliberately exercises a private secret. For production wiring, use a separate public bootstrap target and a configured canonical HTTPS audience as described in [Deliver browser configuration](/docs/env/browser-delivery/). ## Keep endpoint behavior elsewhere [Section titled “Keep endpoint behavior elsewhere”](#keep-endpoint-behavior-elsewhere) The Vite plugin does not decide: * which route returns browser configuration; * where the canonical audience origin comes from; * whether a request needs authentication; * which cache headers the response uses; or * how the application presents a bootstrap failure. Use [Deliver browser configuration](/docs/env/browser-delivery/) for the JSON protocol and [Browser](/docs/env/browser-runtime/) for runtime loading. ## Verify the production artifact [Section titled “Verify the production artifact”](#verify-the-production-artifact) The plugin is one layer, not proof that every application import path is safe. In CI: 1. run `astilba-env generate --check`; 2. build every browser entry with the plugin active; 3. scan production assets for private logical names and binding names; and 4. include a non-production canary value and prove it does not enter browser output. Frameworks or build paths that do not use Vite need an equivalent application-owned rule. # Next.js > Wire generated Env targets and browser projections into Next.js App Router or Pages Router applications. Next.js integration is application-owned wiring around generated Env modules. There is no `@astilba/env/next` export and no framework-specific configuration semantics. Use a generated server target wherever Next.js runs server code. When the browser needs deployment configuration, choose one of two application-owned delivery modes: * return the public envelope from a same-origin JSON route and load it asynchronously with `loadBrowserBootstrap`; or * transport the exact envelope as inert, safely escaped serialized JSON through Next.js and parse it synchronously with `parseBrowserBootstrap`. Both modes use the same generated public projection and validate the same envelope identity. Neither turns the Env envelope into executable JavaScript, writes a `window` global, or interpolates unescaped JSON. `@astilba/env` 0.3.0 has package-consumer evidence for Next.js 15.5.22 and 16.2.12 across App Router static, App Router request, Pages Router static, and Pages Router request modes. The server side of that evidence uses the Node.js runtime. The maintained independent-pnpm example invokes Next’s webpack builder because default Turbopack cannot resolve the exact-registry dependency from that repository fixture layout. The package-consumer matrix also validates a default `next build`; this is not a general Env or Turbopack incompatibility. The Next.js 16 evidence and the maintained examples use the previous caching model with Cache Components disabled. This page does not claim support for `cacheComponents: true`; `dynamic = "force-dynamic"` belongs to that previous model. This page does not claim support for Next.js Edge Runtime or a Next.js deployment on Cloudflare Workers. The separate [Cloudflare Workers](/docs/env/cloudflare-workers/) boundary admits only the documented direct Worker-handler path. ## Generate framework-neutral modules [Section titled “Generate framework-neutral modules”](#generate-framework-neutral-modules) Declare separate browser and server consumers: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.web", entries: { apiOrigin: env.public.deployment.origin(), applicationOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), }, consumers: { browser: env.browser(["apiOrigin", "applicationOrigin"]), server: env.server(["databaseUrl"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", applicationOrigin: "APPLICATION_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", }), }, }); ``` Generate and check the application-owned modules: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` The browser target creates: * `.astilba/env/browserDeployment.server.ts`, which checks the application source; and * `.astilba/env/browser/browser.deployment.ts`, which contains the public projection and decoder without values. ## Keep server and browser modules separate [Section titled “Keep server and browser modules separate”](#keep-server-and-browser-modules-separate) Treat the generated server target and browser projection as two different import roots. A small application-owned layout makes the boundary visible: ```text environment/ ├── public-env.server.ts # server target, source checks, response assembly ├── public-env.client.ts # browser projection and shared readiness promise └── public-env-provider.tsx # React distribution and application UI policy ``` Add `import "server-only"` to the server module and `import "client-only"` to the browser module. Do not re-export both through a `public-env` barrel. A Client Component or `instrumentation-client.ts` that reaches a mixed barrel can pull the generated `*.server.ts` target and `@astilba/env/runtime` into Turbopack’s browser graph. The generated `.server.ts` suffix communicates intent, but the application owns this framework boundary. Import server targets directly from server-owned modules; import only `@astilba/env/browser` and generated `browser/*` projections from client-owned modules. ## Load private configuration on the server [Section titled “Load private configuration on the server”](#load-private-configuration-on-the-server) Import a generated server target only from server-owned code: ```ts import "server-only"; import { load } from "../.astilba/env/serverDeployment.server"; export const configuration = load(process.env); ``` Use `check(process.env)` instead when a route or startup boundary needs to choose its own failure response. Never pass the resulting private configuration through props to a Client Component. ## Choose a browser delivery mode [Section titled “Choose a browser delivery mode”](#choose-a-browser-delivery-mode) | Mode | Use it when | Cost | | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | Same-origin JSON route with `loadBrowserBootstrap` | You want to keep the document shell static and can delay configuration-dependent UI. | One no-store configuration request; dependent UI begins asynchronously. | | Framework-transported JSON with `parseBrowserBootstrap` | Configuration must be available synchronously to the first Client Component render, or the application already renders deployment data on the server. | The server rendering path that produces deployment values must run per request; the envelope participates in the rendered response. | The route mode is a useful default for a static shell. The transported mode removes the extra request; it does not preserve an ambient `env(key)` or `window.__ENV__` API. In either case, pass the validated typed values through an application-owned provider or props. ## Option 1: load through a same-origin JSON route [Section titled “Option 1: load through a same-origin JSON route”](#option-1-load-through-a-same-origin-json-route) Choose this mode when deployment values should vary independently of a static document shell. The route runs on the Node.js runtime; the browser fetches and validates the public envelope after the shell is served. ### Add an App Router endpoint [Section titled “Add an App Router endpoint”](#add-an-app-router-endpoint) Return the exact public envelope from a dynamic route: app/api/env/route.ts ```ts import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import { NextResponse } from "next/server"; import { projection } from "../../../.astilba/env/browser/browser.deployment"; import { check } from "../../../.astilba/env/browserDeployment.server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export const GET = (): NextResponse => { const result = check(process.env); if (!result.ok) { return NextResponse.json( { diagnostics: result.diagnostics, ok: false }, { headers: { "Cache-Control": "private, no-store" }, status: 500, } ); } return NextResponse.json( { audience: { origin: result.value.applicationOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }, { headers: { "Cache-Control": "private, no-store" } } ); }; ``` `APPLICATION_ORIGIN` must be the trusted canonical origin that serves both the page and endpoint. Do not derive it directly from an untrusted `Host` or forwarded header. ### Add a Pages Router endpoint [Section titled “Add a Pages Router endpoint”](#add-a-pages-router-endpoint) The Pages Router uses the same generated modules and envelope. Keep its API handler on the Node.js runtime, not the Edge Runtime: pages/api/env.ts ```ts import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import type { NextApiRequest, NextApiResponse } from "next"; import { projection } from "../../.astilba/env/browser/browser.deployment"; import { check } from "../../.astilba/env/browserDeployment.server"; export default function handler( _request: NextApiRequest, response: NextApiResponse ): void { const result = check(process.env); response.setHeader("Cache-Control", "private, no-store"); if (!result.ok) { response.status(500).json({ diagnostics: result.diagnostics, ok: false, }); return; } response.status(200).json({ audience: { origin: result.value.applicationOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }); } ``` Both response helpers produce the required JSON content type. The Env browser loader also requests with `cache: "no-store"` and refuses redirects. ### Share one browser readiness promise [Section titled “Share one browser readiness promise”](#share-one-browser-readiness-promise) Create one client-only module that owns the bootstrap request. Instrumentation, the provider, and other browser consumers can await the same promise without starting duplicate requests: environment/public-env.client.ts ```ts import "client-only"; import { loadBrowserBootstrap, type ValidatedBootstrap, } from "@astilba/env/browser"; import { type Configuration, projection, } from "../.astilba/env/browser/browser.deployment"; let readiness: Promise> | undefined; export const ensureBrowserEnvironment = (): Promise< ValidatedBootstrap > => { readiness ??= loadBrowserBootstrap({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, projection, requestBaseUrl: window.location.href, }); return readiness; }; ``` The module caches the in-flight, fulfilled, or rejected validation for the current page. This keeps React development remounts and a separate `instrumentation-client.ts` on one request. With the sample above, a rejected promise remains rejected, and the provider stays in its error state until the page reloads. If your application offers retry without a reload, it must deliberately replace the cached promise with a new validated load rather than weakening the audience or projection checks. Start configuration-dependent instrumentation from the same promise without blocking module evaluation, and handle rejection explicitly: instrumentation-client.ts ```ts import { ensureBrowserEnvironment } from "./environment/public-env.client"; import { startInstrumentation } from "./instrumentation"; void ensureBrowserEnvironment() .then( ({ values }) => startInstrumentation(values), () => { // The provider owns the user-visible configuration failure state. } ) .catch(() => { // The application owns instrumentation startup failure reporting. }); ``` Do not use an unhandled top-level `await` or start a second bootstrap request from instrumentation. ### Load before rendering dependent UI [Section titled “Load before rendering dependent UI”](#load-before-rendering-dependent-ui) Create a Client Component that owns loading, success, and failure while the client-only module owns readiness: environment/public-env-provider.tsx ```tsx "use client"; import { createContext, useEffect, useState } from "react"; import type { Configuration } from "../.astilba/env/browser/browser.deployment"; import { ensureBrowserEnvironment } from "./public-env.client"; export const EnvironmentContext = createContext< Readonly | undefined >(undefined); type EnvironmentState = | { status: "loading" } | { status: "ready"; values: Readonly } | { status: "error" }; export function EnvironmentProvider({ children, }: { children: React.ReactNode; }) { const [state, setState] = useState({ status: "loading", }); useEffect(() => { let active = true; void ensureBrowserEnvironment().then( ({ values }) => { if (active) { setState({ status: "ready", values }); } }, () => { if (active) { setState({ status: "error" }); } } ); return () => { active = false; }; }, []); if (state.status === "loading") { return (

Loading configuration.

); } if (state.status === "error") { return

Configuration is unavailable.

; } return ( {children} ); } ``` Mount the provider in `app/layout.tsx` or `pages/_app.tsx`. Keep dependent children out of the tree until validation succeeds, and replace the sample status text with application-specific UI. This mode adds one no-store configuration request before dependent UI can render. ### Diagnose a browser import leak [Section titled “Diagnose a browser import leak”](#diagnose-a-browser-import-leak) If Turbopack reports that it cannot resolve `@astilba/env/runtime` from a Client Component or `instrumentation-client.ts`, inspect the import path before changing package resolution. The usual cause is a client-safe helper importing a barrel that also exports a server component or generated `*.server.ts` target. Split the modules, add Next’s `server-only` and `client-only` sentinels, and confirm that the browser graph reaches only the generated browser projection and `@astilba/env/browser`. Do not alias the runtime to an empty browser module; that would conceal the boundary violation. `serverExternalPackages: ["@astilba/env"]` can be an application-specific server-bundling choice after the import graph is clean. It does not repair a server module that is reachable from browser code. ### Keep the static shell static [Section titled “Keep the static shell static”](#keep-the-static-shell-static) For public build values, import the generated browser `.build.ts` module directly. When deployment values vary, keep the page static and make only the application-owned `/api/env` route `force-dynamic`; the browser loads and validates its public projection after the shell is served. ## Option 2: transport a serialized envelope through Next.js [Section titled “Option 2: transport a serialized envelope through Next.js”](#option-2-transport-a-serialized-envelope-through-nextjs) Choose this mode when the browser needs validated deployment values synchronously. The server constructs the exact envelope, serializes it with `JSON.stringify`, and passes that inert string through Next.js to a Client Component. `parseBrowserBootstrap` validates the serialized JSON without a fetch. For deployment values to vary for one built artifact, the server path that creates the envelope must run at request time. In App Router, mark that path dynamic and keep it on the Node.js runtime. This is a trade-off with the static-shell route mode; it is not Next.js Edge Runtime support. app/layout.tsx ```tsx import "server-only"; import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import { EnvironmentProvider } from "./environment-provider"; import { projection } from "../.astilba/env/browser/browser.deployment"; import { check } from "../.astilba/env/browserDeployment.server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode }>) { const result = check(process.env); if (!result.ok) { throw new Error("Environment configuration is unavailable."); } const serverExpectedOrigin = result.value.applicationOrigin; const source = JSON.stringify({ audience: { origin: serverExpectedOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }); return ( {children} ); } ``` The Client Component receives the serialized source and validates it before distributing the typed values: app/environment-provider.tsx ```tsx "use client"; import { parseBrowserBootstrap } from "@astilba/env/browser"; import { createContext, useMemo } from "react"; import { type Configuration, projection, } from "../.astilba/env/browser/browser.deployment"; export const EnvironmentContext = createContext< Readonly | undefined >(undefined); export function EnvironmentProvider({ children, serverExpectedOrigin, source, }: { children: React.ReactNode; serverExpectedOrigin: string; source: string; }) { const values = useMemo( () => { const expectedAudience = { origin: typeof window === "undefined" ? serverExpectedOrigin : window.location.origin, }; return parseBrowserBootstrap({ expectedAudience, projection, source, }).values; }, [serverExpectedOrigin, source] ); return ( {children} ); } ``` Pass the serialized string as a component prop or use a framework-supported inert data container. Let Next.js serialize and escape the prop or container contents. Do not use `dangerouslySetInnerHTML`, interpolate JSON into HTML, emit a `beforeInteractive` assignment, or write a mutable `window.__ENV__` global. `source.audience` remains the trusted canonical server configuration. During server rendering, the Client Component uses `serverExpectedOrigin` because `window` is unavailable. During browser rendering and hydration, it independently expects `window.location.origin`. A page or proxy that serves an envelope for the wrong origin therefore fails parsing during the browser render or hydration; it does not silently trust the envelope’s own audience field. For local HTTP development, select `serverExpectedOrigin` from the same exact development allowlist used to create `source.audience`. The browser still compares it with its current origin. Follow the [local HTTP audience guidance](/docs/env/browser-delivery/#develop-with-a-local-http-origin); do not derive either value from `Host`, `Forwarded`, or `X-Forwarded-Host`. `parseBrowserBootstrap` throws `BootstrapFailure` when the envelope is missing or invalid. Route that failure to application-specific, perceivable error UI; do not fall back to ambient, baked, or previously cached values. A provider in the root layout needs `app/global-error.tsx` to handle root-layout check or parse errors. A provider mounted by a page can use a nearer `error.tsx` boundary. In Pages Router, export `getServerSideProps` from every page that needs deployment values; it cannot run in `pages/_app.tsx`. Each page passes the serialized source and `serverExpectedOrigin` through `pageProps` to the provider in `_app.tsx`. Do not use `getStaticProps` for deployment values that must change without rebuilding. There is no `@astilba/env/next` export. The [Next static shell example](https://github.com/astilbahq/env/tree/main/examples/next-static-shell) is an executable adoption fixture that keeps framework wiring in the application. Its combined target and request-URL audience are local fixture wiring, not the production route pattern. For production, use the separate public target and trusted configured origin shown on this page. Read [Deliver browser configuration](/docs/env/browser-delivery/) for the complete envelope protocol, canonical-origin, cache, and failure requirements. If you are replacing `DynamicEnvScript`, `clientEnv`, or `serverEnv`, continue with [Migrate from next-dynamic-env](/docs/env/migrate-from-next-dynamic-env/). # Deliver browser configuration > Generate a public projection, deliver an inert same-origin JSON envelope, and validate it before browser application startup. Env accepts browser deployment and request configuration as inert JSON. Your application either owns a same-origin endpoint or transports safely escaped serialized JSON through its framework. The generated projection and browser runtime validate that the envelope belongs to the expected contract, consumer, lifecycle, and origin before application code uses it. Endpoint delivery adds one configuration request. Framework-transported delivery adds no separate request, but the envelope participates in the rendered response. Use a public build entry instead when a value may be fixed in the browser artifact. ## Declare a browser target [Section titled “Declare a browser target”](#declare-a-browser-target) Select only the public entries the browser needs: ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.web", entries: { apiOrigin: env.public.deployment.origin(), applicationOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), }, consumers: { browser: env.browser(["apiOrigin", "applicationOrigin"]), server: env.server(["databaseUrl"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", applicationOrigin: "APPLICATION_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", }), }, }); ``` Generate the modules: ```sh pnpm exec astilba-env generate ``` The browser target produces two different interfaces: * `.astilba/env/browserDeployment.server.ts` checks the application-owned source; and * `.astilba/env/browser/browser.deployment.ts` contains the public projection and typed decoder, but no values. The generated public projection does not contain `databaseUrl`, its source name, or its codec metadata. ## Return the exact envelope [Section titled “Return the exact envelope”](#return-the-exact-envelope) Create a same-origin route that checks the public target and returns the selected values: ```ts import { BOOTSTRAP_PROTOCOL } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; import { check } from "./.astilba/env/browserDeployment.server"; export const environmentResponse = (): Response => { const result = check(process.env); if (!result.ok) { return Response.json( { diagnostics: result.diagnostics, ok: false }, { headers: { "Cache-Control": "private, no-store" }, status: 500, } ); } return Response.json( { audience: { origin: result.value.applicationOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }, { headers: { "Cache-Control": "private, no-store" }, } ); }; ``` The successful envelope has exactly seven top-level fields: | Field | Source | | ------------ | ---------------------------------------------------------------------- | | `protocol` | `BOOTSTRAP_PROTOCOL`, currently `astilba.env.bootstrap/v1`. | | `contract` | Generated `projection.contract`. | | `consumer` | Generated `projection.consumer`. | | `lifecycle` | Generated `projection.lifecycle`. | | `projection` | Generated `projection.digest`. | | `audience` | `{ origin }` derived from trusted canonical application configuration. | | `values` | The successful generated target value. | Do not derive the audience from an untrusted `Host` or forwarded header. If your platform constructs canonical origins at a trusted proxy boundary, test that boundary as application code. Return `application/json` with a 2xx status. Redirects are refused. A response that can vary by request must include `Cache-Control: private, no-store`; the loader also requests every bootstrap with `cache: "no-store"`. The endpoint contains public values, but authentication and authorisation can still matter for request-specific configuration. Env does not choose the route or access policy. ## Validate before use [Section titled “Validate before use”](#validate-before-use) Load the envelope with the generated projection: ```ts import { loadBrowserBootstrap } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; const bootstrap = await loadBrowserBootstrap({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, projection, requestBaseUrl: window.location.href, }); renderApplication(bootstrap.values); ``` The result is typed from the generated projection. Its values are copied into frozen, owned data before return. `loadBrowserBootstrap` refuses: * a request URL outside the expected origin; * fetch failure, redirects, a non-2xx status, or a final cross-origin URL; * a non-JSON content type, invalid UTF-8, invalid JSON, or a body over 65,536 bytes; * missing or unknown envelope fields; * a different audience, protocol, contract, consumer, lifecycle, or projection digest; and * missing, unknown, or invalid typed values. It throws `BootstrapFailure` with a stable `code` such as `BOOTSTRAP_PROJECTION_MISMATCH`. Do not continue with ambient, baked, or previously cached values after a failure. ```ts import { BootstrapFailure, loadBrowserBootstrap, } from "@astilba/env/browser"; try { const bootstrap = await loadBrowserBootstrap(options); renderApplication(bootstrap.values); } catch (error) { const code = error instanceof BootstrapFailure ? error.code : "BOOTSTRAP_UNEXPECTED"; renderConfigurationFailure(code); } ``` Keep failure UI application-specific and perceivable. A retry should perform a new validated load; it should not weaken any expected identity. ## Delay application import [Section titled “Delay application import”](#delay-application-import) Use `startBrowserApplication` when configuration must validate before the main application module enters the browser graph: ```ts import { startBrowserApplication } from "@astilba/env/browser"; import { projection } from "./.astilba/env/browser/browser.deployment"; await startBrowserApplication({ endpoint: "/api/env", expectedAudience: { origin: window.location.origin }, fetch: globalThis.fetch, importApplication: () => import("./application"), projection, requestBaseUrl: window.location.href, }); ``` The imported module must export `start(values, audience)`. Env loads and validates the bootstrap first, imports the application second, then calls `start`. If your framework has already transported the exact envelope as inert data, use `parseBrowserBootstrap({ source, expectedAudience, projection })`. It applies the same envelope and value checks without fetching. Pass the serialized JSON text as `source`, not the result of `JSON.parse`, and let the framework escape it for its inert data container. Do not interpolate unescaped JSON into HTML or turn the envelope into executable JavaScript. ## Develop with a local HTTP origin [Section titled “Develop with a local HTTP origin”](#develop-with-a-local-http-origin) The Env `origin()` codec accepts canonical HTTPS origins and deliberately rejects `localhost` and IP literals. The browser runtime can still validate an exact HTTP `expectedAudience` during local development. Use local HTTPS, or derive a development-only audience in application code from an allowlist of exact local origins such as `http://localhost:` and `http://127.0.0.1:`. The endpoint must emit that same allowlisted origin in `audience.origin` when the browser uses it as `expectedAudience`; do not override only the client-side expectation. Keep this branch out of production, reject forwarded host headers, and never weaken the production canonical-origin check. Env does not add a development fallback for you. For example, select one fixed origin for the current development command, then use the selected value in the endpoint envelope: ```ts const developmentOrigins = Object.freeze({ localhost: "http://localhost:3000", loopback: "http://127.0.0.1:3000", }); const audienceOrigin = process.env.NODE_ENV === "development" ? developmentOrigins.localhost : result.value.applicationOrigin; return Response.json( { audience: { origin: audienceOrigin }, consumer: projection.consumer, contract: projection.contract, lifecycle: projection.lifecycle, projection: projection.digest, protocol: BOOTSTRAP_PROTOCOL, values: result.value, }, { headers: { "Cache-Control": "private, no-store" }, } ); ``` Choose `localhost` or `loopback` explicitly in application-owned development configuration. Do not select between them from `Host`, `Forwarded`, or `X-Forwarded-Host`. ## Use build values without a request [Section titled “Use build values without a request”](#use-build-values-without-a-request) A public build entry is validated during generation and emitted into a browser-only module: ```ts entries: { releaseSha: env.public.build.string({ minimumCodePoints: 7, maximumCodePoints: 64, }), }, consumers: { browser: env.browser(["releaseSha"]), }, targets: { browserBuild: env.process("browser", { releaseSha: "RELEASE_SHA", }), }, ``` Generate with the value available: ```sh RELEASE_SHA=abcdef0 pnpm exec astilba-env generate ``` Then import the frozen value directly: ```ts import { configuration } from "./.astilba/env/browser/browser.build"; configuration.releaseSha; ``` Changing `RELEASE_SHA` requires generation and a new application build. The generated module contains the public value, so do not use build entries for secrets. Keep the browser runtime and generated public modules physically separate from private targets. If Vite builds the browser graph, add the dedicated [Vite boundary](/docs/env/vite/). # Check name inventory drift > Export a value-free target inventory and compare it with an application-owned provider or platform name list. Env 0.3.0 can make one declaration the authority for the configuration names a process target expects. Export the declared names, convert a provider or platform list into Env’s small observed format, then check required presence and explicit namespace ownership in CI. This workflow compares names only. Env does not receive values, contact a provider, inspect secret kinds, sync configuration, or decide which namespace your application owns. ## Export the declared inventory [Section titled “Export the declared inventory”](#export-the-declared-inventory) Choose one generated process target from `astilba.env.ts`: ```sh pnpm exec astilba-env inventory export --target workerDeployment ``` Without `--json`, the command writes one canonical `astilba.env.contract-inventory/v1` document to standard output: ```json { "entries": [ { "entry": "apiKey", "lifecycle": "deployment", "name": "API_KEY", "required": true, "visibility": "private" }, { "entry": "previousApiKey", "lifecycle": "deployment", "name": "PREVIOUS_API_KEY", "required": false, "visibility": "private" } ], "format": "astilba.env.contract-inventory/v1", "target": "workerDeployment" } ``` The inventory contains declared metadata only: logical entry ID, lifecycle, bound source name, required presence, and visibility. It contains no value, value hash, provider kind, routing rule, or provider identity. `inventory export` supports process targets. It refuses unknown, unsupported, empty, malformed, duplicate, or case-folded name mappings rather than emitting ambiguous evidence. ## Convert the observed names [Section titled “Convert the observed names”](#convert-the-observed-names) Keep provider access in application-owned tooling. Query the provider with its supported CLI or API, discard every field except the names, then write this exact document: ```json { "entries": [ { "name": "API_KEY" }, { "name": "UNRELATED_PLATFORM_KEY" } ], "format": "astilba.env.observed-name-inventory/v1" } ``` Env does not accept Wrangler, Infisical, GitHub, or another provider’s native response directly. A thin converter keeps authentication, pagination, destination selection, and provider-specific interpretation outside Env. The observed format is deliberately strict: * the top-level object has only `entries` and `format`; * each entry has only `name`; * names match `[A-Z_][A-Z0-9_]{0,127}` and are unique under ASCII case folding; * at most 2,048 names and 1 MiB of JSON are accepted; and * the CLI rejects symbolic links, invalid UTF-8, malformed JSON, unknown fields, and unsupported formats. Treat the names as sensitive operational metadata. Do not upload expected or observed inventories as CI artefacts by default. ## Choose ownership explicitly [Section titled “Choose ownership explicitly”](#choose-ownership-explicitly) Check an open namespace when the destination legitimately contains names owned by other applications or tools: ```sh pnpm exec astilba-env inventory check \ --target workerDeployment \ --observed ./observed-names.json ``` Open ownership is the default. Missing required names fail; missing optional names and unexpected names are notices. Use closed ownership only when this target owns the complete observed namespace: ```sh pnpm exec astilba-env inventory check \ --target workerDeployment \ --observed ./observed-names.json \ --ownership closed ``` Closed ownership makes an unexpected name fail. Env never infers closed ownership from the target, provider, file name, or CI environment. ## Interpret the result [Section titled “Interpret the result”](#interpret-the-result) The check reports three issue codes: | Code | Meaning | Fails open ownership | Fails closed ownership | | ------------------ | ------------------------------------------------- | -------------------- | ---------------------- | | `REQUIRED_MISSING` | A declared `required: true` name is absent. | Yes | Yes | | `OPTIONAL_MISSING` | A declared `required: false` name is absent. | No | No | | `UNEXPECTED_ENTRY` | An observed name is outside the target inventory. | No | Yes | `required: false` models optional presence, such as an empty rotation slot. It is not warning severity. Keep application-specific warnings and escalation policy outside Env. For CI, add `--json` and verify the response discriminator before consuming the report: ```sh pnpm exec astilba-env inventory check \ --target workerDeployment \ --observed ./observed-names.json \ --ownership closed \ --json ``` ```json { "command": "inventory", "format": "astilba.env.cli.inventory/v1", "ok": false, "operation": "check", "report": { "format": "astilba.env.inventory-check/v1", "issues": [ { "code": "OPTIONAL_MISSING", "entry": "previousApiKey", "name": "PREVIOUS_API_KEY" }, { "code": "UNEXPECTED_ENTRY", "entry": null, "name": "UNRELATED_PLATFORM_KEY" } ], "ownership": "closed", "pass": false, "target": "workerDeployment" } } ``` Exit `0` means the inventory is acceptable under the selected ownership mode. Exit `1` means drift or invalid operational evidence. Exit `2` is reserved for command-line misuse. ## Keep delivery separate [Section titled “Keep delivery separate”](#keep-delivery-separate) An inventory pass proves only that the supplied list contains the expected names. It does not prove that: * a value is non-empty or valid; * a provider stored the value as a secret; * the application received the current value; * the observed list came from the intended account, project, or environment; or * a sync or prune operation is safe. Use the generated target’s `check(source)` or `load(source)` operation for runtime value validation. Keep provider sync, prune, routing, authentication, and destination selection in the system that already owns delivery. # Migrate from next-dynamic-env > Replace next-dynamic-env proxies and script injection with an explicit Env contract and generated application boundaries. [`next-dynamic-env`](https://github.com/ReesMorris/next-dynamic-env) is retired and deprecated on npm. Astilba Env covers its build-once deployment use case, but does not preserve that package’s API or runtime mechanism. The migration replaces ambient proxies and script injection with an explicit contract, generated browser and server modules, and application-owned inert JSON delivery. Treat this as an architectural migration, not a package rename. Install the exact public-alpha release before declaring the replacement contract: ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` ## Map the concepts [Section titled “Map the concepts”](#map-the-concepts) | `next-dynamic-env` | Astilba Env 0.3 | | --------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | | `createDynamicEnv({ client, server })` | [`defineEnvironment({ entries, consumers, targets })`](/docs/env/declaration-reference/#defineenvironment) | | `clientEnv` proxy | Generated browser build configuration or validated bootstrap values | | `serverEnv` proxy | Generated server target `check` or `load` | | `DynamicEnvScript` | Application-owned JSON endpoint plus `loadBrowserBootstrap` | | `waitForEnv` | Await `loadBrowserBootstrap` or use `startBrowserApplication` | | Inline `window.__NEXT_DYNAMIC_ENV__` value | Inert, same-origin JSON response | | Validator tuple beside each current value | Lifecycle-aware codec in the declaration | | Automatic validation skip during `next build` | Explicit build, deployment, and request lifecycles | | `emptyStringAsUndefined` global option | Per-codec blank, required, and normalisation policy | | Framework package with one root export | Framework-neutral root, browser, runtime, and Vite boundaries | There is no `@astilba/env/next` export. App Router and Pages Router integrations use application code around the same generated modules. ## Replace the runtime declaration [Section titled “Replace the runtime declaration”](#replace-the-runtime-declaration) A `next-dynamic-env` declaration reads current values while it constructs `clientEnv` and `serverEnv`. An Env declaration describes values without reading them: astilba.env.ts ```ts import { defineEnvironment, env } from "@astilba/env"; export default defineEnvironment({ id: "com.example.web", entries: { apiOrigin: env.public.deployment.origin(), applicationOrigin: env.public.deployment.origin(), databaseUrl: env.private.deployment.secret(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), }, consumers: { browser: env.browser(["apiOrigin", "applicationOrigin"]), server: env.server(["databaseUrl", "port"]), }, targets: { browserDeployment: env.process("browser", { apiOrigin: "API_ORIGIN", applicationOrigin: "APPLICATION_ORIGIN", }), serverDeployment: env.process("server", { databaseUrl: "DATABASE_URL", port: "PORT", }), }, }); ``` Public exposure is determined by both the entry visibility and the browser consumer selection. A variable name does not become public merely because it has a `NEXT_PUBLIC_` prefix. Generate and check the application-owned interfaces: ```sh pnpm exec astilba-env generate pnpm exec astilba-env generate --check ``` ## Replace `serverEnv` [Section titled “Replace serverEnv”](#replace-serverenv) Replace ambient proxy access with an explicit generated target: ```ts import "server-only"; import { load } from "./.astilba/env/serverDeployment.server"; const configuration = load(process.env); configuration.databaseUrl; configuration.port; ``` Use `check` where application code needs to choose the failure response. Diagnostics contain stable codes and logical identities where appropriate; they do not echo configuration values. ## Replace browser injection [Section titled “Replace browser injection”](#replace-browser-injection) Remove `DynamicEnvScript`, the mutable `clientEnv` proxy, and `waitForEnv`. Add the application-owned Next.js JSON route and Client Component described in [Next.js](/docs/env/nextjs/). The replacement has three explicit pieces: 1. a generated server target checks the public source values; 2. the route returns the exact public envelope with `Cache-Control: private, no-store`; and 3. `@astilba/env/browser` validates the same-origin response before dependent UI renders. Use [Deliver browser configuration](/docs/env/browser-delivery/) when you need the complete framework-neutral protocol and failure behavior. Do not keep both delivery mechanisms active. Once the JSON bootstrap path passes application tests, remove the inline script and every read from `window.__NEXT_DYNAMIC_ENV__`. ## Understand the validation differences [Section titled “Understand the validation differences”](#understand-the-validation-differences) | Previous behavior | Migration decision | | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------- | | Validation ran while `createDynamicEnv` built its proxies | Env checks a generated target when your code calls `check` or `load`. | | Validation was automatically skipped during `next build` | Mark values as `build`, `deployment`, or `request`; there is no automatic Next build bypass. | | Raw values could be accepted without a schema | Choose an explicit [Env codec](/docs/env/declaration-reference/#built-in-codecs). | | One global option converted empty strings to missing values | Configure blank and required behavior on each codec. | | Validator transforms supplied defaults or arbitrary output types | Use a built-in codec, a private opaque schema, or application validation after loading. | | Validation errors could throw, warn, or call a handler | Use `check` for an explicit result or `load` for an exception. | | Client and server values lived behind runtime proxies | Generated browser and server modules create a static import boundary. | The browser projection accepts only Env’s portable public codecs. Arbitrary schemas and opaque transforms cannot enter a browser consumer. ## Migrate Yup validation deliberately [Section titled “Migrate Yup validation deliberately”](#migrate-yup-validation-deliberately) Prefer built-in codecs for common configuration: ```ts entries: { enabled: env.public.deployment.boolean(), mode: env.public.deployment.enum(["standard", "compact"]), origin: env.public.deployment.origin(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), } ``` For a genuinely custom private transform, use an `opaque` entry and pass an exactly typed, synchronous Standard Schema v1 implementation to the generated target. A Yup adapter can wrap `validateSync`; it must translate success or failure into the Standard Schema result without exposing the rejected value. Read [Validation and Standard Schema](/docs/env/validation-and-standard-schema/) for the exact type, runtime, CLI, and portability limits. If the Yup schema represents application business rules instead of configuration syntax, load a built-in private value and validate it after the Env boundary. ## Account for intentional non-compatibilities [Section titled “Account for intentional non-compatibilities”](#account-for-intentional-non-compatibilities) Env does not provide compatibility exports or shims for: * `createDynamicEnv`; * `clientEnv` or `serverEnv`; * `DynamicEnvScript`; * `waitForEnv`; * `window.__NEXT_DYNAMIC_ENV__`; * `__raw`; * `skipValidation` or automatic build-phase detection; * `onValidationError`; * a global `emptyStringAsUndefined` switch; * implicit `NEXT_PUBLIC_*` exposure; or * `@astilba/env/next`. These omissions preserve explicit lifecycles, static artifact boundaries, and inert browser delivery. ## Verify and remove the old package [Section titled “Verify and remove the old package”](#verify-and-remove-the-old-package) Before removing `next-dynamic-env`: 1. run `pnpm exec astilba-env generate --check` in CI; 2. fail application startup or the endpoint when a required deployment value is missing; 3. verify browser bundles contain no private entry name, binding name, or value; 4. verify the JSON response has the expected audience and `Cache-Control: private, no-store`; 5. change deployment values without rebuilding and confirm one built artifact observes the new values; 6. exercise successful and rejected bootstrap responses; and 7. remove every import, script component, and global reference from `next-dynamic-env`. Remove the old dependency only after those checks pass: ```sh pnpm remove next-dynamic-env ``` The Env package tests exercise App Router and Pages Router builds in static and request modes. Your route, proxy trust, startup policy, and failure UI still require application tests. # Lifecycles and projections > Model when configuration becomes available and keep each application artifact on a physically separate projection. Env separates two decisions that ambient environment access usually combines: 1. the **lifecycle** says when a value may be resolved; and 2. the **consumer projection** says which artifact may know that the entry exists. The result is one declaration with several generated interfaces, not one mutable configuration object shared across the application. ## Choose the lifecycle [Section titled “Choose the lifecycle”](#choose-the-lifecycle) | Lifecycle | Resolve it when | Artifact effect | | ------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `build` | Producing an application artifact | A browser-selected value is emitted into a generated browser `.build.ts` module; a server-only selection resolves through a generated Node.js build target. Changing either requires another build. | | `deployment` | Starting or configuring one deployment | Application artifact bytes stay unchanged. Server code loads it from an explicit source; browser code receives it through a validated bootstrap. | | `request` | Handling one request or tenant context | Pass a request-owned source to the generated target. Do not retain the result in process-global state or an unpartitioned cache. | Only public entries have a build builder. Env does not offer `env.private.build`: embedding a private value in an artifact would make the artifact configuration-specific and copy the value into durable bytes. A browser consumer that selects build entries requires exactly one complete build target so generation has one unambiguous source mapping. Use deployment values for configuration that should change between environments without rebuilding. Use request values only when the value genuinely differs per request or tenant; the explicit source and lifetime are part of the safety boundary. ## Select a consumer projection [Section titled “Select a consumer projection”](#select-a-consumer-projection) A consumer chooses the exact logical entries one artifact needs: ```ts consumers: { browser: env.browser(["apiOrigin", "featureMode"]), worker: env.server(["apiOrigin", "databaseUrl", "tenantId"]), } ``` `env.browser(...)` accepts public, browser-portable entries only. Its generated projection contains public entry identities, portable decoders, and a compatibility digest. It excludes: * private entry names and codecs; * private source bindings; * server-only codecs; * co-presence rules and their entries; * complete contract metadata; and * configuration values. `env.server(...)` may select public and private entries. Calling `env.server()` with no list selects every declared entry. Prefer an explicit list when a process has more than one independently deployed artifact. Calling `env.browser()` with no list also selects every entry, then rejects the declaration if any selection is private, uses a server-only codec, or belongs to a co-presence rule. Co-presence rules are server-projection only in 0.3. An explicit browser list makes the exposure decision easier to review. ## Bind one complete lifecycle per target [Section titled “Bind one complete lifecycle per target”](#bind-one-complete-lifecycle-per-target) A process target maps one consumer and one lifecycle to names in an application-owned source record: ```ts targets: { serverDeployment: env.process("worker", { apiOrigin: "API_ORIGIN", databaseUrl: "DATABASE_URL", }), serverRequest: env.process("worker", { tenantId: "TENANT_ID", }), } ``` Each target must bind every selected entry for exactly one lifecycle. A target cannot mix deployment and request bindings or omit one selected deployment entry. You can define alternate complete targets for the same consumer and lifecycle when your application needs different source mappings. The generated module accepts any plain source record, not only `process.env`: ```ts import { load } from "./.astilba/env/serverRequest.server"; export const handleRequest = (request: Request): Response => { const configuration = load({ TENANT_ID: readTrustedTenant(request), }); return respondForTenant(configuration.tenantId); }; ``` Env validates the supplied record and returns an owned configuration value. Your application remains responsible for authenticating the request, deriving the source, and limiting the value’s lifetime. ## Understand the generated separation [Section titled “Understand the generated separation”](#understand-the-generated-separation) Generation writes these kinds of files under `.astilba/env/`: | Output | Purpose | | ---------------------------------- | ---------------------------------------------------------------------------- | | `.server.ts` | Typed `check` and `load` functions for one process target. | | `browser/.build.ts` | Frozen public build values for direct browser import. | | `browser/.deployment.ts` | Public deployment projection and decoder; contains no values. | | `browser/.request.ts` | Public request projection and decoder; contains no values. | | `consumers/.*.json` | Value-free public or server projection evidence. | | `contract.json` | Complete value-free contract evidence. Keep this file out of browser graphs. | | `snapshot.json` | Value-free planning input used by `plan --base`. | | `manifest.json` | The exact generated-directory file list and format. | The generated server and metadata files can contain private logical names and source bindings. Physical separation works only when your build graph imports browser modules deliberately. Add the [Vite boundary plugin](/docs/env/vite/) where Vite builds browser code. ## Treat compatibility as exact or unknown [Section titled “Treat compatibility as exact or unknown”](#treat-compatibility-as-exact-or-unknown) Env derives projection digests from the declared contract, not the current values. A matching digest proves that the consumer sees the same declared projection. Opaque validators are different: their `semantics` and `revision` fields describe compatibility, but Env cannot prove that two arbitrary implementations behave the same. Planning therefore reports unknown confidence where a safe conclusion is unavailable. This is compatibility evidence, not secret management. Env never provisions a value source, rotates a secret, or verifies that a live provider contains the declared value. # Validation and Standard Schema > Choose portable built-in codecs, understand redacted failures, and reserve opaque Standard Schema validation for private Node.js targets. Env validates configuration at an explicit lifecycle boundary. The declaration records the accepted source form and typed output; a generated target applies that contract when application code calls `check` or `load`. Prefer a first-party codec whenever it can express the configuration. Use an `opaque` Standard Schema validator only for private server semantics that Env cannot represent portably. ## Start with built-in codecs [Section titled “Start with built-in codecs”](#start-with-built-in-codecs) Built-in codecs make validation deterministic and portable: ```ts entries: { enabled: env.public.deployment.boolean(), mode: env.public.deployment.enum(["standard", "compact"]), origin: env.public.deployment.origin(), port: env.private.deployment.safeInteger({ maximum: 65_535, minimum: 1, }), } ``` They define exact details such as case-sensitive Boolean tokens, canonical HTTPS origins, numeric ranges, blank handling, required presence, and bounded strings or JSON shapes. Portable codecs can participate in public browser projections. A subset also works in the admitted [Cloudflare Workers deployment-target path](/docs/env/cloudflare-workers/). Check [Declaration reference](/docs/env/declaration-reference/#built-in-codecs) for each builder’s input and portability. ## Choose the failure contract [Section titled “Choose the failure contract”](#choose-the-failure-contract) Generated targets expose two operations: ```ts const result = await check(source); const configuration = await load(source); ``` `check` returns an explicit result: ```ts if (!result.ok) { console.error("Configuration is invalid.", result.diagnostics); } else { startApplication(result.value); } ``` `load` returns the same typed configuration or throws `EnvironmentConfigurationError`. This `await` form also works for targets that use built-in codecs only. Both paths redact rejected values. Diagnostics may contain a stable error code, consumer, entry, lifecycle, or rule identity; they do not contain the value, a fragment, length, or hash. Do not log the source object around that boundary. ## Use `opaque` for private custom semantics [Section titled “Use opaque for private custom semantics”](#use-opaque-for-private-custom-semantics) An opaque entry declares value-free input and output shapes plus an application-owned semantic identity: ```ts serviceOptions: env.private.deployment.opaque({ input: { kind: "string" }, output: { kind: "object", properties: [ { name: "region", required: true, shape: { kind: "string" }, }, ], }, revision: "1", semantics: "com.example.service-options/v1", }) ``` Generation makes the schema requirement explicit in the target type: ```ts import type { StandardSchemaV1 } from "@astilba/env/runtime"; import { load } from "./.astilba/env/serverDeployment.server"; type ServiceOptions = Readonly<{ region: string }>; const serviceOptions: StandardSchemaV1 = { "~standard": { validate(input) { if (typeof input === "string") { try { const value: unknown = JSON.parse(input); if ( typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 1 && "region" in value && typeof value.region === "string" ) { return { value: Object.freeze({ region: value.region }), }; } } catch { // Return the same redacted issue as any other invalid input. } } return { issues: [{ message: "Invalid service options." }], }; }, vendor: "example", version: 1, }, }; const configuration = await load(process.env, { serviceOptions }); ``` The schema’s declared input and output types must exactly match the declaration shapes. Extra or missing schema keys fail the generated type gate. Env has no first-party Zod adapter or named schema-library guarantee. You may pass any implementation that structurally satisfies Standard Schema v1 for the declared private opaque Node.js target; verify that library and schema version in your application, and keep its validation synchronous. ## Keep validation synchronous [Section titled “Keep validation synchronous”](#keep-validation-synchronous) Env 0.3.0 requires opaque Standard Schema validation to settle synchronously. A returned promise produces `ENV_VALIDATOR_ASYNC_UNSUPPORTED`. Because the generated operation accepts an arbitrary validator implementation, `check` and `load` return promises for a target that contains an opaque entry even when the validator settles synchronously. The CLI cannot validate an opaque target because it does not have your application schema map. Import the generated operation and pass the schemas in application code or a focused test. ## Respect the runtime boundaries [Section titled “Respect the runtime boundaries”](#respect-the-runtime-boundaries) Opaque entries are: * private; * server-only; * unavailable to browser consumers; and * not admitted in the Cloudflare Workers support claim. The exact input/output shapes, `semantics`, and `revision` provide value-free compatibility evidence. Env cannot prove that two arbitrary validator implementations behave identically, so planning reports `UNKNOWN` when compatibility depends on opaque behavior. If custom validation belongs to business rules rather than the configuration boundary, load a built-in `text`, `secret`, or `json` value first and validate it in application code. # Declaration reference > Reference the Env 0.3 declaration fields, entry builders, codecs, consumers, targets, and co-presence rules. The root `@astilba/env` export contains two runtime values: ```ts import { defineEnvironment, env } from "@astilba/env"; ``` `defineEnvironment` validates one complete declaration and returns an opaque `EnvironmentDefinition`. The `env` object creates branded entries, consumers, targets, and rules that only `defineEnvironment` can compile. `EnvironmentDefinition` is also available as a type-only export. Use it to annotate a boundary that accepts any compiled Env declaration; do not construct or inspect one yourself. ## `defineEnvironment` [Section titled “defineEnvironment”](#defineenvironment) ```ts defineEnvironment({ id, entries, consumers, targets, rules, }); ``` | Field | Requirement | | ----------- | ----------------------------------------------------------------------------------------------------------------- | | `id` | A lowercase reverse-DNS identifier such as `com.example.application`. | | `entries` | One or more logical entries created by an `env.public.*` or `env.private.*` builder. | | `consumers` | One or more named `env.browser(...)` or `env.server(...)` selections. | | `targets` | One or more named `env.process(...)` mappings. Each target binds one complete lifecycle selected by its consumer. | | `rules` | Optional array of `env.together(...)` co-presence rules. | Entry, consumer, target, and rule identifiers start with a lowercase ASCII letter, contain only ASCII letters or digits, and have at most 64 characters. Identifiers are also unique under ASCII case folding. Process source names use the environment-variable form `[A-Z_][A-Z0-9_]{0,127}`. ## Visibility and lifecycle builders [Section titled “Visibility and lifecycle builders”](#visibility-and-lifecycle-builders) Choose visibility and lifecycle before the codec: ```ts env.public.build env.public.deployment env.public.request env.private.deployment env.private.request ``` Every builder below supports `required: false`. Entries are required by default. There is no private build builder. `secret` and `opaque` are available only on private deployment and request builders. ## Built-in codecs [Section titled “Built-in codecs”](#built-in-codecs) The source column describes an `env.process` source such as `process.env`. The browser bootstrap receives already-typed JSON values and validates them against the same portable contract. | Builder | Source and output | Important options and defaults | Browser portable | | ------------------------ | ----------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | | `boolean(options?)` | Exact source token to `boolean`. | `trueInput: "true"`; `falseInput: "false"`; `blank: "missing"`. | Yes | | `enum(values, options?)` | Exact source string to the declared string union. | 1–1,024 unique portable strings; see the declaration limits below. | Yes | | `integer(options)` | Trimmed signed decimal to a safe integer in range. | Required `minimum` and `maximum`; `blank: "missing"`. | No | | `json(shape, options?)` | Bounded JSON text to the exact typed shape. | `blank: "missing"`. | Yes | | `origin(options?)` | Canonical HTTPS origin string. | No path, query, fragment, credentials, IP literal, or `localhost`; default port and trailing slash are normalised away. | Yes | | `safeInteger(options)` | Canonical decimal to a safe integer in range. | Required `minimum` and `maximum`; no leading `+`, whitespace, or non-canonical leading zero; `blank: "missing"`. | Yes | | `string(options?)` | Preserved portable string. | `minimumCodePoints: 0`; `maximumCodePoints: 65_535`. Empty string is valid unless you raise the minimum. | Yes | | `stringList(options?)` | Comma-separated source to a readonly string array. | Empty items `drop`; defaults to 0–64 items and 1–1,024 code points per item. | Yes | | `text(options?)` | Optional trim-aware server string. | `normalise: "preserve"`; `blank: "missing"`; 1–65,535 code points. | No | | `secret(options?)` | Preserved private string with no trimming. | `blank: "missing"`; 1–65,535 code points. | No; private only | | `opaque(options)` | Private source string through a caller-supplied synchronous Standard Schema v1 validator. | Exact `input` and `output` shapes plus value-free `semantics` and `revision`. | No; private only | `integer` accepts conventional signed, whitespace-trimmed server input. Use `safeInteger` when the same canonical decimal contract must work in server and browser projections. `text` treats a whitespace-only value as blank even when `normalise` is `"preserve"`. `string` preserves and can accept an empty string. Choose deliberately instead of relying on a global empty-string policy. ### Boolean options [Section titled “Boolean options”](#boolean-options) ```ts enabled: env.public.deployment.boolean({ blank: "invalid", falseInput: "disabled", trueInput: "enabled", }) ``` `trueInput` and `falseInput` are case-sensitive, non-empty printable ASCII tokens and must differ. ### String-list options [Section titled “String-list options”](#string-list-options) ```ts regions: env.public.deployment.stringList({ emptyItems: "invalid", minimumItems: 1, maximumItems: 8, minimumItemCodePoints: 2, maximumItemCodePoints: 32, }) ``` The separator is always a comma. Items are not trimmed automatically. ### Portable JSON shapes [Section titled “Portable JSON shapes”](#portable-json-shapes) `json` uses data-only shape descriptors: ```ts clientConfiguration: env.public.deployment.json({ kind: "object", properties: [ { name: "region", required: true, shape: { kind: "string" }, }, { name: "retryCount", required: false, shape: { kind: "safe-integer", minimum: 0, maximum: 5, }, }, ], }) ``` Shape kinds are: * `string`, `boolean`, and `null`; * `safe-integer` with `minimum` and `maximum`; * `array` with `items`, `minimumItems`, and `maximumItems`; and * `object` with named, required or optional `properties`. Objects are exact: unknown properties are rejected. Values are copied into frozen, owned data before the application receives them. ### Declaration limits [Section titled “Declaration limits”](#declaration-limits) Env bounds declaration size and portable value work before runtime resolution: | Surface | Limit | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Enum choices | 1–1,024 unique choices; each choice is at most 65,535 UTF-8 bytes and all choices together are at most 65,536 bytes. | | String lists | Defaults to at most 64 items and 1,024 code points per item. Options may raise those values to at most 1,024 items or 65,536 code points per item, while `maximumItems × maximumItemCodePoints` must not exceed 65,536. | | Portable shapes | At most 8 levels and 256 shape nodes. Arrays declare at most 1,024 items; objects declare at most 256 unique keys. | | Portable object keys | At most 255 UTF-8 bytes; `__proto__`, `constructor`, and `prototype` are rejected. | | Portable string values | At most 65,536 UTF-8 bytes when copied through a declared portable shape. | ### Opaque schemas [Section titled “Opaque schemas”](#opaque-schemas) Use `opaque` only for private semantics that a built-in codec cannot express: ```ts serviceOptions: env.private.deployment.opaque({ input: { kind: "string" }, output: { kind: "object", properties: [ { name: "region", required: true, shape: { kind: "string" }, }, ], }, semantics: "com.example.service-options/v1", revision: "1", }) ``` The generated target requires a schema map and returns promises: ```ts import { load } from "./.astilba/env/serverDeployment.server"; const configuration = await load(process.env, { serviceOptions: serviceOptionsSchema, }); ``` The schema’s declared input and output types must exactly match the shapes in the declaration. Validation must settle synchronously; a returned promise produces `ENV_VALIDATOR_ASYNC_UNSUPPORTED`. An opaque input shape is either `{ kind: "string" }` or an optional string wrapper: ```ts input: { kind: "optional", value: { kind: "string" }, } ``` An opaque output may use any portable shape above or wrap one with the same `optional` form. Entry presence and schema input are separate decisions: * with `required: false` and a non-optional input, a missing source omits the entry without calling the validator; * with an optional input, a missing source calls the validator with `undefined`; and * if the validator returns `undefined` for an optional output, a required entry fails with `ENV_MISSING_VALUE` while an optional entry is omitted. Returning `undefined` for a non-optional output fails with `ENV_INVALID_VALUE`. The entry builder’s `required` option controls whether the whole entry may be absent. A `required` flag inside an object shape controls only that named output property. Omitting a required property, adding an unknown property, or returning another value that does not match the declared shape fails with `ENV_INVALID_VALUE`; omitting a property marked `required: false` is valid. The CLI `check` command cannot accept application schema implementations. Validate an opaque target through its generated `check` or `load` function. ## Consumers [Section titled “Consumers”](#consumers) ```ts env.browser(["apiOrigin", "featureMode"]) env.server(["databaseUrl", "port"]) ``` An explicit list must contain at least one unique entry. Omitting the list selects every declared entry: ```ts env.server() ``` Browser consumers may select only public entries using `boolean`, `enum`, `json`, `origin`, `safeInteger`, `string`, or `stringList`. They cannot select an entry that belongs to an `env.together` rule; co-presence rules are server-projection only in 0.3. A browser consumer that selects one or more build entries requires exactly one complete build target. This gives generation one unambiguous source mapping for the emitted browser values. ## Process targets [Section titled “Process targets”](#process-targets) ```ts env.process("server", { databaseUrl: "DATABASE_URL", port: "PORT", }) ``` The first argument names an existing consumer. The record maps logical entry names to raw source names. A target must bind all entries selected by that consumer for one lifecycle. Split build, deployment, and request bindings into separate targets. One target cannot map two logical entries to the same raw source name. You can define alternate complete targets for the same consumer and lifecycle when your application needs different source mappings. ## Co-presence rules [Section titled “Co-presence rules”](#co-presence-rules) Use `env.together` when optional entries form one configuration unit: ```ts rules: [ env.together("smtpCredentials", [ "smtpHost", "smtpUser", "smtpPassword", ]), ] ``` Resolution succeeds when all rule entries are present or all are absent. A partial set returns `ENV_RULE_VIOLATION` with the logical rule and entry names, not their values. Each rule requires at least two unique entries from the same lifecycle. Co-presence rules are server-projection only in 0.3; browser consumers cannot select rule entries. A server consumer that selects any entry in a rule must select every entry in that rule. Keep a co-presence rule within one operational configuration unit. # CLI reference > Generate Env modules, validate values, compare name inventories, plan contract changes, and consume stable machine output. The `astilba-env` command compiles `astilba.env.ts` in the current package. It generates project-owned interfaces, checks current values without exposing them, compares value-free name inventories, and compares value-free planning snapshots. Run it through your package manager: ```sh pnpm exec astilba-env generate ``` Inside `package.json` scripts, call `astilba-env` directly. ## Command summary [Section titled “Command summary”](#command-summary) ```text astilba-env generate [--config PATH] [--check] [--json] astilba-env check --target ID [--config PATH] [--json] astilba-env inventory export --target ID [--config PATH] [--json] astilba-env inventory check --target ID --observed PATH [--ownership open|closed] [--config PATH] [--json] astilba-env plan --base GIT_REF [--config PATH] [--json] ``` Options use a separate value token. `--config=custom.mts` is not accepted. The default configuration is `astilba.env.ts`. `--config` accepts lowercase `.ts` and `.mts` files. A `.ts` file must be inside an ESM package with `"type": "module"`. ## `generate` [Section titled “generate”](#generate) Compile the declaration and replace the owned `.astilba/env/` directory: ```sh pnpm exec astilba-env generate ``` Generation writes: * typed server target modules; * typed browser build or projection modules; * full and consumer-specific value-free contract JSON; * `snapshot.json` for planning; and * `manifest.json` binding the exact generated tree. When a public browser consumer has build entries, generation reads the corresponding build target names from the current process environment and emits the validated public values into `browser/.build.ts`. Env refuses to replace a generated directory that is malformed, contains symbolic links, has an unsupported format, or contains unowned files. Generated files start with an ownership marker where appropriate; do not edit them manually. ### Check drift [Section titled “Check drift”](#check-drift) ```sh pnpm exec astilba-env generate --check ``` This compiles the declaration and compares every expected byte without writing. It fails if a file is missing, changed, or unexpected. Use it as a CI gate: ```json { "scripts": { "env:check": "astilba-env generate --check" } } ``` The CI environment must supply any public build values needed to reproduce `.build.ts` modules. ## `check` [Section titled “check”](#check) Validate one named process target against the current environment: ```sh pnpm exec astilba-env check --target serverDeployment ``` `check` prints one validity statement. It does not print resolved values. With `--json`, a failure includes redacted diagnostic codes and logical identities: ```json { "command": "check", "diagnostics": [ { "code": "ENV_MISSING_VALUE", "consumer": "server", "entry": "databaseUrl", "lifecycle": "deployment" } ], "format": "astilba.env.cli.check/v1", "ok": false, "target": "serverDeployment" } ``` The exact diagnostic fields depend on the failure. Values, value fragments, lengths, and hashes are not included. The CLI cannot validate an `opaque` entry because it has no application schema implementation. Import the target’s generated `check(source, schemas)` function instead. ## `inventory export` [Section titled “inventory export”](#inventory-export) Compile the declared names for one process target: ```sh pnpm exec astilba-env inventory export --target serverDeployment ``` Without `--json`, the command writes a canonical `astilba.env.contract-inventory/v1` document. It contains logical entry IDs, source names, lifecycle, visibility, and required presence; it contains no values or provider-kind claims. With `--json`, the document is the `inventory` field inside `astilba.env.cli.inventory/v1`. ## `inventory check` [Section titled “inventory check”](#inventory-check) Compare the target with a strict application-supplied name list: ```sh pnpm exec astilba-env inventory check \ --target serverDeployment \ --observed ./observed-names.json \ --ownership closed ``` The observed document uses `astilba.env.observed-name-inventory/v1` and contains only `{ "name": "..." }` entries. Env does not query a provider or accept provider-native output; convert the provider’s response in application-owned tooling. Ownership defaults to `open`. Required absence fails in both modes. Optional absence is a notice. Unexpected names are notices in open mode and failures in explicitly selected closed mode. Read [Check name inventory drift](/docs/env/inventory-and-drift/) for the schemas, issue codes, trust boundary, and CI workflow. ## `plan` [Section titled “plan”](#plan) Compare the current declaration with a generated snapshot committed at a Git revision: ```sh pnpm exec astilba-env plan --base origin/main ``` Env reads `.astilba/env/snapshot.json` from the resolved base commit and compiles the current declaration. It does not execute the historical `astilba.env.ts`. Plain output tells you whether actions are required. Use `--json` for the value-free impact plan: ```sh pnpm exec astilba-env plan --base origin/main --json ``` The plan can call for actions such as: * rebuilding or activating an application artifact; * adding, reconfiguring, or removing configuration; * rebuilding an adapter; * revalidating a target; or * performing manual or security review. Confidence is `PROVEN` only when the declared change supports an exact conclusion. Opaque or otherwise unprovable compatibility remains `UNKNOWN`. `plan` compares declarations and bindings. It does not inspect live provider state, current values, secret-manager contents, or configuration drift outside the generated snapshot. ## `--json` [Section titled “--json”](#--json) Add `--json` to any command for one canonical JSON object. Successful results go to standard output. Command and usage errors go to standard error. Machine formats are versioned independently: | Command | Success format | | ---------------------------------------- | ------------------------------ | | `generate` | `astilba.env.cli.generate/v1` | | `check` | `astilba.env.cli.check/v1` | | `inventory export` and `inventory check` | `astilba.env.cli.inventory/v1` | | `plan` | `astilba.env.cli.plan/v1` | | Any command error | `astilba.env.cli.error/v1` | Check the `format` field before consuming other fields. Treat a newer or unknown discriminator as unsupported instead of guessing its meaning. ## Exit statuses [Section titled “Exit statuses”](#exit-statuses) | Status | Meaning | | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------- | | `0` | Command completed successfully. An inventory is acceptable under its ownership mode; a plan has no consumer with `UNKNOWN` confidence. | | `1` | Invalid configuration, stale or invalid generated output, inventory drift or invalid evidence, command failure, or a plan with unknown confidence. | | `2` | Invalid command syntax, target name, configuration extension, or Git reference. | Do not parse human-readable output for automation. Use `--json`, the versioned format field, and the exit status together. # Release and support > Check the package, CLI, runtime, framework, and public-alpha boundaries of Astilba Env 0.3.0. `@astilba/env` 0.3.0 is a public alpha. The package and source are public, so applications can evaluate the complete contract, but the API may change deliberately before a stable release. Install the exact alpha when reproducibility matters: ```sh pnpm add @astilba/env@0.3.0 --save-exact ``` Version 0.3.0 adds provider-neutral contract inventory export and name-drift checking for process targets. It adds no provider client, value sync, provider-kind evidence, JavaScript package export, browser API, runtime API, or generated-module protocol. Version 0.2.3 added complete TSDoc coverage and the executable adoption-example suite. ## Supported public surface [Section titled “Supported public surface”](#supported-public-surface) | Surface | 0.3.0 status | | ---------------------------------------------------- | --------------------------------------------------------------------- | | `defineEnvironment` and `env` builders | Public on supported Node.js releases | | `astilba-env generate` and `generate --check` | Public on supported Node.js releases | | `astilba-env check --target ID` | Public for targets the CLI can validate | | `astilba-env inventory export` and `inventory check` | Public for provider-neutral process-target name evidence | | `astilba-env plan --base GIT_REF` | Public on supported Node.js releases | | Generated Node.js `check` and `load` functions | Public | | Generated Cloudflare Workers deployment targets | Public with first-party codecs; see the narrow runtime boundary below | | Generated public browser projections | Public | | Inert same-origin JSON browser protocol | Public | | Vite private-module boundary | Public for Vite 8.1.5 or later within Vite 8 | | Next.js App and Pages Router wiring | Application-owned integration; no Astilba adapter | | Hosted configuration service | Not provided | | Secret storage or provider provisioning | Not provided | | Stable API compatibility | Not promised during the public alpha | ## Runtime matrix [Section titled “Runtime matrix”](#runtime-matrix) | Operation | Node.js | Browser | Cloudflare Workers | | ------------------------------------------------------------------ | -------------------------------------------------------------------------------------- | --------- | --------------------------------------- | | Import `@astilba/env` and author declarations | Supported | Blocked | Blocked | | Run the generator, CLI, or planner | Supported | Blocked | Blocked | | Import a generated server target with first-party codecs | Supported | Blocked | Supported for deployment lifecycle only | | Import a generated target with `opaque` Standard Schema validators | Supported | Blocked | Not admitted | | Import `@astilba/env/browser` | Supported for server-side envelope assembly, but not as a Node.js configuration target | Supported | Blocked | | Import generated public browser modules | Supported for server-side envelope assembly and build tooling | Supported | Blocked | | Import `@astilba/env/vite` | Supported in Vite configuration | Blocked | Blocked | The package supports these Node.js ranges: * Node.js 22.14.0 or later within Node 22; * Node.js 24 within Node 24; and * Node.js 26 within Node 26. The Cloudflare Workers path does not require `nodejs_compat` for Env. It accepts a Wrangler-generated `Env` binding interface without an index signature, reads only the declared binding names, and leaves unrelated capability bindings to application code. Runtime support is export-specific. Evidence for the generated runtime does not make the declaration builders, CLI, browser runtime, or Vite plugin portable to workerd. ## Executable evidence [Section titled “Executable evidence”](#executable-evidence) Support claims are backed by package-consumer and maintained-example runs rather than inferred from type declarations alone: | Evidence lane | Exercised versions and boundary | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | Node.js and TypeScript | Node.js 22.14.0, 22.23.2, 24.18.1, and 26.5.1; TypeScript 6.0.3 and 7.0.2. | | Package managers | Clean exact-registry consumers with npm and pnpm. | | Operating systems | Linux release lanes and a Windows Node.js 22.14.0 package-consumer lane. | | Vite | Vite 8.1.5 package-consumer evidence for the browser boundary. | | Next.js | Next.js 15.5.22 and 16.2.12 across App Router static, App Router request, Pages Router static, and Pages Router request modes. | | Cloudflare Workers | Wrangler 4.115.0 with compatibility date `2026-07-29` and bundled workerd `1.20260722.1`; only the narrow generated deployment-target path documented below is admitted. | | Portable runtime comparison | Bun 1.3.14 exercises portable generated-runtime equivalence; this is not a declaration-authoring, generator, or CLI support claim. | | Maintained examples | Exact-registry applications for Node.js, Cloudflare Workers, a Next.js static shell, and Vite. Check each example’s lockfile for its admitted package version. | The maintained Next.js example is an isolated pnpm application and deliberately invokes Next’s webpack builder because default Turbopack cannot resolve the exact-registry dependency from that repository fixture layout. The package-consumer matrix also passes a default `next build`; do not infer a general Env or Turbopack incompatibility from the example command. ## Package boundaries [Section titled “Package boundaries”](#package-boundaries) | Import or command | Responsibility | | ---------------------- | ------------------------------------------------------------------------------------------------------ | | `@astilba/env` | Declaration builders on Node.js | | `@astilba/env/runtime` | Runtime operations used by generated server targets; exposed to admitted Node.js and workerd consumers | | `@astilba/env/browser` | Public browser bootstrap loading and validation | | `@astilba/env/vite` | Node.js Vite boundary that rejects private Env modules from browser graphs | | `astilba-env` | Node.js command-line interface | There is no `@astilba/env/next` export. Next.js support uses the same generated modules and browser protocol as another framework. ## Cloudflare Workers boundary [Section titled “Cloudflare Workers boundary”](#cloudflare-workers-boundary) Env’s admitted Workers surface is deliberately limited to: * a generated server target; * the `deployment` lifecycle; * first-party Env codecs; * a direct `check(env)` or `load(env)` call inside the handler; and * application-owned Wrangler `vars`, `secrets.required`, secret values, and capability bindings. Request-lifecycle targets and arbitrary Standard Schema validator graphs are not part of the Workers support claim. Env does not inspect live bindings, store secrets, call provider APIs, provision resources, or plan automatic redeployments. Read [Cloudflare Workers](/docs/env/cloudflare-workers/) before adopting this path. ## Browser boundary [Section titled “Browser boundary”](#browser-boundary) Browser deployment and request values must arrive as same-origin JSON. The runtime validates: * response status, redirect state, content type, and size; * the expected audience origin; * the bootstrap protocol; * contract, consumer, lifecycle, and projection identity; and * the exact generated value projection. The runtime fetches with `cache: "no-store"`. An application endpoint whose response can vary by request must also send `Cache-Control: private, no-store`. Env does not inject inline JavaScript, write to `window`, mutate HTML, or choose an application route. ## Alpha boundaries [Section titled “Alpha boundaries”](#alpha-boundaries) Plan for these constraints in 0.3: * generated files are application-owned build artifacts and must be regenerated when the declaration changes; * public build values selected by a browser consumer require an explicit build source during generation; * custom Standard Schema validation is available only for private server `opaque` entries; * browser projections use Env’s portable built-in codecs; * compatibility plans contain descriptors and change classifications, never configuration values; * diagnostics are deliberately redacted; and * framework-specific startup, routing, authentication, and failure UI remain application responsibilities. The inventory CLI is also deliberately narrow. It compares declared process-target names with a strict application-supplied name list. It does not query providers, inspect values or provider kinds, infer closed ownership, sync or prune configuration, or replace runtime `check` and `load` validation. The public [Env repository](https://github.com/astilbahq/env) is the source and issue tracker for the alpha. Report a contract, generation, runtime, or browser-boundary defect there. # Overview > Understand what Astilba Cache does, where it runs, and whether it fits your application. Astilba Cache stores the result of expensive server-side work so later calls can reuse it. It is designed for TypeScript applications that read from databases, APIs, or other services and need explicit control over invalidation, failures, and who may share a cached value. Development preview `@astilba/cache` is not published to npm and has no supported production setup. The Cache repository is not publicly accessible. The reviewed source snapshot contains publish-shaped Cloudflare Workers and React Router entry points, but elapsed TTL and grace periods are still not enforced and the full runtime path has not completed its release gates. These docs explain that snapshot; they are not a release announcement or download path. ## Decide whether Cache fits [Section titled “Decide whether Cache fits”](#decide-whether-cache-fits) | You want to… | Fit | | ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | Reuse a database query or API response on the server | This is the intended use case. | | Invalidate related server data after a mutation | Tags and soft or hard invalidation are core features. | | Keep user-specific results out of shared storage | Privacy-aware scopes are built into the key and write path. | | Cache browser requests or React component state | Use a client data-fetching or state library instead. Astilba Cache is a server-side cache. | | Evaluate a Cloudflare Workers integration | The source includes an I/O-free Workers factory, KV driver, Coordinator Durable Object, live Bus, request-driven recovery, and React Router middleware. | | Add a supported production cache today | Not yet. The package, elapsed-time behavior, operational measurements, and release process must complete first. | For React applications, “server-side” means code such as a route loader, server action, API route, or Server Component—not code running in the browser. The current framework adapter targets React Router v8 on the server; see [React Router](/docs/cache/react-and-server-apps/). ## Start with one value [Section titled “Start with one value”](#start-with-one-value) Every basic cache operation has three application-level parts: 1. A **key** identifies the result, such as `product:sku-123`. 2. A **factory** loads the value when Cache cannot reuse a stored copy. 3. A **store** holds the encoded result for a later call. ```ts import { compound } from "@astilba/cache" const product = await cache.getOrSet({ key: `product:${productId}`, tags: [compound("product", productId)], factory: async ({ signal }) => loadProduct(productId, signal), }) ``` On a miss, Cache runs `loadProduct()`, stores its result, and returns it. On a usable hit, it returns the stored value without running the factory. The portable constructor makes you supply storage, a clock, and a random source. The [source walkthrough](/docs/cache/quickstart/) shows that explicit wiring. On Workers, `createWorkersCache()` supplies those platform capabilities and composes the current Cloudflare drivers for you. ## Add only what you need [Section titled “Add only what you need”](#add-only-what-you-need) | Need | Add | Leave it out when… | | -------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------- | | Exercise the kernel locally | A bounded `memory()` Store used as a development L2 | You are wiring a real shared runtime. | | Run on Cloudflare Workers | `createWorkersCache()`, a KV namespace, and the Coordinator Durable Object | You are only reviewing the portable API. | | Use React Router v8 | The root `cacheMiddleware()` and `nodejs_compat` compatibility flag on Workers | Your framework owns request context another way. | | Invalidate related values | Dependency tags and a Registry | Values never need explicit invalidation. | | Reuse values inside one running server | L1, a local Store | One shared Store is sufficient. | | Coordinate invalidation across servers | Registry and Bus; keep L2 for fills and durable delta replay | The application has only one cache instance or does not invalidate. | | Cache user or tenant data | Request identity and an explicit scope where sharing is intended | Every value is truly public and its key covers all inputs. | | Reuse stale data during an outage | Grace plus a failure classifier | Origin failures should always surface. | | Coordinate fills across servers | A Lock driver and `lock: true` | In-process singleflight is enough. | | Change the wire format | A custom Codec | JSON-representable values are sufficient. | | Tag a shared HTTP response safely | React Router render collection and scope-aware `Cache-Tag` emission | Your framework owns response dependencies another way. | | Purge a shared HTTP or CDN cache | A future CDN driver; response tags exist, but purge delivery is not wired yet | You only need application data caching. | Most application code should begin with `getOrSet()`. Registry, Bus, locks, codecs, replication, and HTTP response caching belong to progressively more advanced setups. The Workers factory hides most coordination wiring; the raw contracts remain available to adapter authors. ## Understand the guarantees [Section titled “Understand the guarantees”](#understand-the-guarantees) * **Uncertain invalidation knowledge never becomes false freshness.** Cache may perform extra origin work while distributed state reconverges. * **Soft and hard invalidation are different.** Soft expiry permits refresh and eligible stale fallback; hard deletion makes older values unreadable. * **Identity affects storage.** Principal-derived values stay in local storage unless the application deliberately declares a shareable scope. * **Failures are classified.** A transient outage may reuse an eligible stale value. Fact-like statuses such as 403, 404, and 410 are not retriable by default; an opted-in 404 follows the separate negative-cache rule below. * **Not-found facts are explicit.** When a call takes the opted-in negative disposition for a 404, it resolves `undefined`; a grace-eligible stale value may suppress that disposition. Before storing the negative, Cache checks L2 and refuses the write when it finds a decodable value whose invalidation verdict is fresh or stale, or cannot be established. Negative entries never become grace or stale-on-error values. * **Strong reads pay for authority.** With coordinated invalidation configured, they perform a live Registry check before serving a stored entry and before a strong miss is filled. * **Serve metadata reports evidence, not guesses.** Entry tier and age describe the value actually served; durability is omitted when an L1 hit cannot prove whether an L2 copy exists. * **A private dependency makes the rendered response private.** The React Router adapter emits response tags only when every managed dependency has readable public scope. Cache does not update your source of truth. Change the database or upstream service first, then invalidate its cached representations. ## Current release status [Section titled “Current release status”](#current-release-status) | Surface | Status | Detail | | ------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Correctness kernel | Implemented | Read, fill, scope, codec, resilience, and invalidation behavior is exercised by deterministic tests. | | Driver contracts | Implemented | Application and runtime integrations can implement the typed capability boundaries. | | Local memory Store | Implemented in source | `memory()` provides bounded, per-instance LRU storage and optional physical TTL when given a Clock. | | Cloudflare path | Unreleased source preview | `./cloudflare` exports the Workers factory, KV, Coordinator, Registry, Bus, and tick-driven redial helpers; workerd integration tests cover the primary path. | | React Router path | Unreleased source preview | `./react-router` exports server middleware, typed request context access, request-entry recovery lifecycle adoption, and scope-aware response-tag collection. | | Local chaos demo | Source-only evidence app | An unpublished React Router app runs three demo-side binding failures against the real Workers composition without claiming production timing or an SLO. | | Composed-app boot witness | Required source CI lane | The built demo boots on local workerd with its real Coordinator and KV bindings, proves a healthy Bus reaches `established`, and proves an armed refusal reports `never-established` plus `bus_dial_failed`. It is not a deployed measurement. | | Public package | Not released | npm has no `@astilba/cache` package, so there is no supported installation or production deployment path. | ## Choose a path [Section titled “Choose a path”](#choose-a-path) | You want to… | Start with | Continue with | | --------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | | Decide whether the preview fits | This overview | [Implementation status](/docs/cache/api-status/) | | Review the smallest current-source example | [Source walkthrough](/docs/cache/quickstart/) | [Read and cache values](/docs/cache/reading-and-filling/) | | Evaluate the runtime adapters | [Cloudflare Workers](/docs/cache/cloudflare-workers/) | [React Router](/docs/cache/react-and-server-apps/) if it is your server framework | | Understand the local Workers failure evidence | [Cloudflare Workers](/docs/cache/cloudflare-workers/#understand-the-local-chaos-evidence) | [Implementation status](/docs/cache/api-status/) for the remaining measurement boundary | | Add application cache behavior | [Read and cache values](/docs/cache/reading-and-filling/) | [Invalidate cached data](/docs/cache/tags-and-invalidation/) and [Control cache sharing](/docs/cache/scopes-and-privacy/) | | Tag rendered responses safely | [React Router](/docs/cache/react-and-server-apps/) | [Cache HTTP responses](/docs/cache/response-caching/) | | Diagnose a value or event | [Inspect cache behavior](/docs/cache/observability/) | [API reference](/docs/cache/api-reference/) | | Understand the correctness model | [Cache fundamentals](/docs/cache/core-concepts/) | [How Cache works](/docs/cache/how-it-works/) and [Consistency and resilience](/docs/cache/consistency-and-resilience/) | | Inspect capability and export details | [Runtime architecture](/docs/cache/architecture/) | [Driver implementations](/docs/cache/drivers-and-status/) and [API reference](/docs/cache/api-reference/) | # Implementation status > Check which Astilba Cache methods, helpers, drivers, and adapters work in the current unreleased source build. Astilba Cache is unreleased. The portable correctness kernel and the main Workers source path are active. The Workers composition now keeps construction I/O-free, acquires request-scoped platform handles lazily, and carries its own request-driven recovery ticks. React Router provides scope-aware L3 dependency collection and response tags. Elapsed TTL and grace policy, CDN purge delivery, several convenience methods, and production release gates remain incomplete. Use this page as the preview ledger. “Implemented” means behavior exists in the reviewed source snapshot and is exercised by the repository’s test lanes. It does not mean the package has an npm installation or production support commitment. This ledger was reviewed against Cache source commit `726e1b0` from 27 July 2026. The Cache repository is not publicly accessible, so the identifier is maintainer provenance rather than a checkout link. Later source changes are not documented here until this snapshot advances. Use [API reference](/docs/cache/api-reference/) for exact exported shapes. When another page and this ledger appear to differ, follow this page and report the stale page. ## Configuration requirements [Section titled “Configuration requirements”](#configuration-requirements) | Requirement | Current boundary | | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `namespace`, `clock`, `rng` | Required by raw `createCache()`. `createWorkersCache()` supplies Clock and Rng. | | `l2` | Required when a read must run its factory. Without L2, a fill throws `NotImplementedError`. | | `l1` | Optional. Required if principal-derived, L1-only values should survive beyond the current call. The Workers factory supplies bounded `memory()`. | | `registry` | Required by `expire()`, `delete()`, and `clear()`. | | `registry` + `bus` + `l2` | Required for coordinated read validation and mirror recovery. Registry plus Bus without L2 throws at construction. | | Workers bindings | `createWorkersCache()` requires a stable name, KV namespace, and Coordinator Durable Object namespace. The Coordinator separately requires the same KV namespace under `REGISTRY_KV`. | | Workers compatibility | Use a compatibility date of 2024-09-23 or later and enable `nodejs_compat`. The root package uses `node:crypto`, and that flag also supplies the AsyncLocalStorage support the React Router adapter needs. `nodejs_als` alone cannot boot the package. | ## Implemented in the root API [Section titled “Implemented in the root API”](#implemented-in-the-root-api) | Surface | Current behavior | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createCache()` | Builds the cache facade and optional invalidation reader and poller without performing I/O. Retention registration and Bus establishment are deferred until the first read or purge that uses them. | | `getOrSet()` | Reads L1 then L2 or fills a value with compatible in-isolate singleflight. If a shared factory fails transiently, a waiting caller can still make its own stale-on-error decision using the stale candidate it read before joining. When an opted-in 404 takes the negative disposition, it resolves as `undefined`; callers using the plain form must include `undefined` in `T`. A terminal fenced fill throws `FencedError`. | | `getOrSetEntry()` | Adds `skip()` and returns value, the tier actually observed, stale and skip state, elapsed age, optional serve-on-error metadata, and only the durability evidence that serve can prove. After a shared transient failure, it preserves the entry form’s miss result when its own candidate is no longer servable. A fresh cached negative reports `value: undefined` with the actual tier and durability evidence. | | Consistency defaults | A per-call `consistency` overrides `defaults.consistency`; an omitted value otherwise resolves to eventual. | | Strong reads and fills | Live-check stored entries and pre-check strong misses when coordinated invalidation is configured. Registry failure throws `RegistryUnavailableError`, or `defaults.onUnavailable: “eventual”` degrades that call and emits `strong_degraded`. A verified mid-fill hard purge can trigger a bounded re-mint and refetch. | | Unknown error posture | `unknownPolicy: “error”` throws `UnknownTagError` instead of refilling. `takedownSensitive` selects the same outcome and outranks an explicit unknown policy. | | `expire()`, `delete()`, `clear()` | Apply soft, hard, and namespace invalidation through a supplied Registry. | | `collect()` | Records explicit dependencies and decides scope, timing, and byte/count budget eligibility at header commit. React Router binds it per request so served hits and fills contribute tags and scope evidence automatically. | | `explain()` | Witnesses a default-public key in L1 then L2, its stored identity, current local invalidation verdict and reader state, including Bus channel state, and any request-scoped render attribution. It distinguishes `present` and proven `absent`, and returns `read-failed` when a classified Store read was suppressed and no tier supplies the entry. | | Factory dependency tags | `setTags()` replaces the call-level base and `dependsOn()` adds memberships. The validated settle-time union is stored and used by render collection and write-back fencing. | | Classified Store failures | Retryable classified L2 writes are suppressed into a non-durable fill. A classified Store read failure emits `store_read_suppressed` and is a plain serving-path miss; unclassified read failures propagate unchanged. | | L2 negative-write guard | Before writing a negative, Cache reads the current L2 entry and applies the serving path’s codec and invalidation checks. It refuses the write when it finds a decodable value that is fresh or stale, or when that value’s invalidation verdict cannot be established. The value remains stored and the attempted negative is non-durable. | | `memory()` | Provides a bounded per-instance LRU Store. It enforces `maxEntries`, UTF-8 `maxBytes`, and Store-level expiry when constructed with a Clock. | | `t`, `compound()` | Build branded tags. `t` validates the complete final grammar and byte budget; `compound()` provides positional delimiter escaping. | | `duration()` | Builds computed duration strings and rejects non-positive, non-finite, fractional-millisecond, or unsafe products with `InvalidDurationError`. | | `httpError()`, `isRetriableHttp()` | Preserve HTTP status information and implement the default stale-on-error classifier. | | Custom `Codec` and `Lock` | Participate in decode safety, fill identity, cross-isolate exclusion, and write arbitration when supplied. | | Telemetry | Uses the exported `TELEMETRY_EVENTS` catalog, emits plain events or HMAC-pseudonymizes hosted string fields, and isolates sink throws or rejections with optional `onSinkError`. | Scope resolution, negative-entry safety, serve-time stale revalidation, codec identity checks, classified L2 write failures, eventual fail-closed behavior, snapshot-capable recovery, and write-back fencing are also implemented. With `notFoundTtl`, an `HttpError` 404 has three singleflight dispositions: * A factory-running caller with `grace` and a still-servable stale value suppresses the negative write. Every compatible joiner inherits that value and its evidence. * If that candidate revalidates as dead or unknown, the leader attempts one negative L2 write and every compatible joiner inherits the negative result. * If the leader has no grace-eligible candidate, it attempts the negative once and shares the not-found fact. A waiter with `grace` and a still-servable stale value may return it; one without a servable stale candidate returns `undefined`. Serve-time validation can still throw when the configured Registry-unavailable posture does not permit a verdict. The L2 negative-write guard resolves a Registry gap toward refusal rather than risking displacement. The current 404 outcome still surfaces `undefined` at `tier: “origin”` with `durable: false`, but L2 is unchanged and no L1 cleanup runs. The guard does not inspect L1. Codec-incompatible, undecodable, or dead L2 bytes do not trigger it; normal newer-envelope arbitration still applies afterward. Both a guard refusal and a stale candidate suppressing the 404 emit `neg_suppressed`. A retryable Store write failure is a separate non-durable outcome. A waiter that returns its own candidate retains that candidate’s evidence. ## Implemented source adapters and evidence [Section titled “Implemented source adapters and evidence”](#implemented-source-adapters-and-evidence) | Entry point | Current behavior | | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `@astilba/cache/cloudflare` | Exports `createWorkersCache`, `cloudflareKV`, `Coordinator`, `doRegistry`, `doBus`, `redialingDoBus`, and their public configuration types. | | `@astilba/cache/react-router` | Exports root server middleware, typed Cache context, current request access, default L3 budget and ineligibility event constants, and the observable poll-tick event and interval constants. | | Recovery poller | Observes pointer liveness, runs bounded delta and snapshot recovery, and backs baseline polling off on failure. `createWorkersCache()` drives both polling and lazy Bus redial from request-time reads; React Router can additionally adopt middleware ticks into the response lifecycle with `waitUntil`. | | Local chaos demo | An unpublished React Router v8 app composes the real Workers factory with local KV and Coordinator bindings. Demo-owned wrappers inject a KV backend failure, refuse future Bus dials, or make mirror reads unavailable while leaving live Registry RPC intact. The last fault does not close an already-established socket. The app prints observed outcomes beside documented permissions and asserts no SLO. | | Composed demo boot witness | A required CI lane builds that demo, boots its emitted Worker on local workerd with the real Coordinator and KV bindings, and reads a machine-facing status route. The healthy arm must reach `established`; an armed Bus-dial refusal is the negative control and must report `never-established` plus `bus_dial_failed`. The lane proves the app is wired to expose those states, not kernel semantics or deployed behavior. | The two adapter subpaths are present in the source and publish export map, but no package has been published. The poller is an internal seam and the demo is an unpublished workspace app; neither creates another public package entry point. ## Partial or provisional behavior [Section titled “Partial or provisional behavior”](#partial-or-provisional-behavior) | Surface | Current boundary | | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | TTL and grace | Duration builders work, but stored TTL and grace fields are zero and elapsed expiry is not enforced. Defaults for TTL and grace are not applied. Entry `age` is now measured from the served envelope’s `bornMs` fill-start timestamp; it is observability, not TTL enforcement. | | `notFoundTtl` | Its presence allows an `HttpError` 404 to take one negative L2 write attempt, but the duration is not enforced. A negative fill and a later read that reaches the invalidation-fresh negative surface `undefined`, never the internal stored placeholder. A refused negative or suppressed retryable write failure leaves the origin outcome non-durable. Negative entries remain excluded from grace and stale-on-error. | | Negative L1 cleanup | A negative result that reaches the serve path skips L1 hydration and best-effort deletes any older L1 copy unless the L2 negative-write guard refused the write. This cleanup still runs after a classified retryable L2 write failure. A failed L1 delete does not block the call; the older value can remain readable until that tier evicts it or a later value fill overwrites it. A guard refusal leaves both tiers unchanged. | | Eventual stale refresh | The stale value is returned, but refresh work is currently awaited before that response resolves. Background adoption, queue retry, and latency decoupling are incomplete. | | Factory cancellation and grace context | The factory receives a fresh `AbortSignal`, but the kernel does not abort it on a cache deadline. `ctx.graced` is not populated. | | `compound()` validation | Positional escaping works, but compound parts do not receive `t`’s remaining character or 256-byte validation in the reviewed snapshot. Keep parts grammar-safe. | | React Router response caching | Automatic dependency and scope collection, `Cache-Tag` emission, safe private demotion, immutable-response rebuilding, and an overridable 16 KB / 1,000-occurrence budget work. The middleware never invents `public` or `s-maxage`; the application must opt into shared caching. | | Factory response qualifier | `FactoryCtx.dependsOn(tag, { l3: false })` throws until stored per-tag emission metadata exists. The same qualifier works on `RenderCollector.dependsOn()`. | | Explain scope and authority | `explain(key)` addresses only the default public canonical key. It uses current local knowledge and performs no live check, resync, or L1 hydration. | | Telemetry catalog | `neg_suppressed` is live for a 404 suppressed in favor of a grace-eligible stale value or refused by the L2 negative-write guard. `registry_degraded` and `state_stale` are reserved names with no emit sites. `createWorkersCache()` accepts kernel telemetry, but its fixed internal memory L1 still has no separate `private_evicted` sink. React Router middleware telemetry also remains an explicit option. | | Purge result | `matchedHint` is always `“unknown”`; `flushed()` and `edgePurged()` resolve without tracking the promised completion boundaries. | | Key invalidation | `{ key }` targets the contextless public canonical key only. Use dependency tags for tenant or principal-derived variants. | | Scope-qualified tag selectors | The selector type accepts `scope`, but Registry tag resolution ignores it. A tag purge affects every entry carrying that tag. | | Lock option | `lock: true` uses a configured Lock; without one it silently continues without cross-isolate locking. No production Lock adapter is exported. | | Coordinator journal | Durable and replayable, but append-only without production checkpointing or truncation. | | Workers measurements | Workerd lanes, the local chaos demo, and the required composed-app boot witness cover local composition and failure posture. Deployed consistency, propagation, caching, and production-threshold measurements are still pending. | `defaults.consistency`, `unknownPolicy`, `onUnavailable`, `takedownSensitive`, `maxEntryRetention`, `maxSyncLag`, and `heartbeatInterval` are consumed. `maxSyncLag` tunes the attached recovery poller; `heartbeatInterval` tunes the reader’s silence threshold and must be coordinated separately with the Coordinator deployment variable. ## Throwing placeholders [Section titled “Throwing placeholders”](#throwing-placeholders) These exports or paths currently throw `NotImplementedError`: * `globalTag()` * `expireAll()` and `deleteAll()` * `FactoryCtx.reuseGraced()` * `FactoryCtx.setTtl()` * `FactoryCtx.dependsOn(tag, { l3: false })` * a factory fill with no L2 Store * `expire()`, `delete()`, or `clear()` without a Registry ## Declared but inert surfaces [Section titled “Declared but inert surfaces”](#declared-but-inert-surfaces) These APIs currently return normally or exist in the types but do not apply their intended behavior: * `CacheConfig.cdn` and the `cdn` mode on `delete()` do not invoke a CDN driver. * `defaults.graceBackoff` is not consumed. * `Tier` includes `“l1.5”`, but the current read path does not emit it. `expireAll()` and `deleteAll()` still require their explicit origin-load guard at the type level, but the methods do not run yet. ## Integrations not present [Section titled “Integrations not present”](#integrations-not-present) There is no Redis or Valkey adapter, production Lock, CDN purge path, or deployed-probe package. React Router is the only shared-response adapter. Its fixture and the Cloudflare integration Worker are tests, while the local chaos demo is an evidence app; none of them is a production application template. Development tags are not package releases The repository’s `v0.1` tag records the sealed correctness-kernel milestone. npm still has no `@astilba/cache` package. Keep using the Unreleased documentation until an installable release exists. ## Related [Section titled “Related”](#related) * [Source walkthrough](/docs/cache/quickstart/) follows the smallest reviewed source configuration. * [Cloudflare Workers](/docs/cache/cloudflare-workers/) documents the current runtime factory and bindings. * [Cache HTTP responses](/docs/cache/response-caching/) documents the implemented L3 collection and header boundary. * [Inspect cache behavior](/docs/cache/observability/) documents `explain()` and the telemetry catalog. * [API reference](/docs/cache/api-reference/) documents every root and adapter export. * [Runtime architecture](/docs/cache/architecture/) maps configuration requirements to capability contracts. * [Driver implementations](/docs/cache/drivers-and-status/) gives component-level implementation detail. # Source walkthrough > Follow one Astilba Cache miss and hit through the implemented memory Store in the reviewed source snapshot. This walkthrough follows one product through the smallest configuration in the reviewed source snapshot. The first read calls the factory; the second reuses the stored result. Source example, not a runnable public quickstart `@astilba/cache` is not available from npm, and the Cache repository is not publicly accessible. Use this page to understand the reviewed implementation. It is not a public checkout or production installation recipe. ## Understand the setup [Section titled “Understand the setup”](#understand-the-setup) The portable constructor requires explicit capabilities: | Part | Purpose here | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | `createCache()` | Creates the application-facing cache. | | `memory()` | Supplies a bounded, per-instance Store. The source example uses it as a development L2 because a factory fill currently requires L2. | | `Clock` | Supplies logical time to the portable kernel. | | `Rng` | Supplies randomness without hard-coding a platform source into the kernel. | | Factory | Loads the product when no stored value is usable. | No Registry or Bus is configured, so this example demonstrates read, fill, and reuse—not distributed invalidation. ## Follow one product read [Section titled “Follow one product read”](#follow-one-product-read) cache.ts ```ts import { createCache, memory, t } from "@astilba/cache" import type { Clock, Rng } from "@astilba/cache" interface Product { id: string name: string } const clock: Clock = { now: () => Date.now() } const rng: Rng = { next: () => Math.random() } // memory() is normally an isolate-local L1. Using it as L2 here gives the // unreleased kernel the Store it requires without pretending this is durable. const developmentStore = memory({ clock, maxEntries: 512, maxBytes: 5_000_000, }) const cache = createCache({ namespace: "storefront", clock, rng, l2: developmentStore, }) const productId = "sku-123" let originLoads = 0 async function loadProduct( id: string, signal: AbortSignal, ): Promise { if (signal.aborted) throw signal.reason originLoads += 1 return { id, name: "Canvas backpack" } } const options = { key: `product:${productId}`, tags: [t`product:${productId}`], factory: ({ signal }: { signal: AbortSignal }) => loadProduct(productId, signal), } const first = await cache.getOrSet(options) const second = await cache.getOrSet(options) console.log(first, second, originLoads) // same product, one origin load ``` The first call misses, runs `loadProduct()`, encodes the result, and writes it to the supplied Store. The second call resolves the same canonical key and reads that stored value. The tag records a dependency you could later invalidate after adding a Registry. The application-facing portion begins at `options`: choose a stable key, declare dependency tags, and provide the origin factory. A runtime adapter should own most of the construction above it. ## Know what is and is not exercised [Section titled “Know what is and is not exercised”](#know-what-is-and-is-not-exercised) The reviewed source path exercises: * the implemented `memory()` Store, including its entry and byte bounds; * the implemented `t` tag builder; * the root `createCache()` and `getOrSet()` paths; * the built-in JSON codec and one in-isolate singleflight identity. It deliberately leaves out: * durability or sharing across isolates; * explicit invalidation, which requires a Registry; * live invalidation delivery and recovery, which require Registry, Bus, and L2 together; * elapsed TTL and grace behavior, which remains unfinished even though the options are typed; * request identity, cross-isolate locks, telemetry, and the React Router response-cache integration. Why `memory()` is passed as L2 The current kernel refuses a factory fill without an L2 Store. The same small `Store` contract is used by both tiers, so `memory()` is sufficient for this source example. It does not become shared or durable simply because it occupies the `l2` configuration field. ## Move to a runtime integration [Section titled “Move to a runtime integration”](#move-to-a-runtime-integration) * [Cloudflare Workers](/docs/cache/cloudflare-workers/) replaces the manual clock, random source, and development Store with the current Workers factory and drivers. * [React Router](/docs/cache/react-and-server-apps/) exposes that cache through server middleware and carries request identity to reads. * [Cache fundamentals](/docs/cache/core-concepts/) explains the storage and invalidation vocabulary. * [Inspect cache behavior](/docs/cache/observability/) shows how to inspect a cache value with `explain()` and configure event telemetry. * [Implementation status](/docs/cache/api-status/) lists every incomplete or provisional surface. # Cache fundamentals > Learn the Astilba Cache vocabulary from a basic read through distributed invalidation. You can understand Astilba Cache without starting with its distributed machinery. Begin with a key, a factory, and a Store. Add the remaining components only when an application spreads across isolates, machines, and shared caches. ## Begin with the read path [Section titled “Begin with the read path”](#begin-with-the-read-path) | Term | Plain-language meaning | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------- | | **Cache instance** | The object returned by `createCache()`. Application code reads and invalidates through it. | | **Key** | The application-facing name of one result, such as `product:sku-123`. | | **Factory** | The async function that loads the value on a miss. The database or upstream API it calls is the **origin** or **source of truth**. | | **Hit** | Cache found a stored value that is legal to serve. | | **Miss** | Cache could not reuse a stored value, so it runs the factory or reports a miss outcome. | | **Entry** | A stored value plus metadata such as its tags, scope, codec identity, and invalidation epoch. | | **Namespace** | A stable boundary around one cache domain. `clear()` advances that namespace so older keys become unreachable. | | **Tag** | A dependency label. Several keys can carry the same tag and be invalidated together. | | **Scope** | A sharing rule: public, tenant-scoped, or principal-derived and local-only. | `getOrSet()` returns only the value. `getOrSetEntry()` also reports where it came from, its elapsed age, whether it was stale or skipped, and whatever durability this serve can prove. ## Understand the storage tiers [Section titled “Understand the storage tiers”](#understand-the-storage-tiers) The tier names describe where a value lives, not different value formats. | Tier | What it is | Typical use | | ---------- | ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **L1** | An optional Store local to one process or worker isolate. Other instances cannot see it. | Fast repeat reads and retention of principal-derived values that must not enter shared storage. | | **L2** | A Store shared or durable across calls and, depending on the driver, across instances. | The main reusable server-side copy. The current kernel requires L2 whenever a factory must run. The source walkthrough uses `memory()` in this slot only as a non-durable demonstration. | | **L3** | A shared HTTP or CDN response cache outside the value-store path. | Caching rendered responses. The React Router adapter can emit safe dependency tags; CDN purge delivery is not implemented. | | **Origin** | The factory result before or while it is written to a Store. | The database or upstream request supplied by the application. | Both L1 and L2 implement the same `Store` contract. A runtime decides whether that Store is an in-memory map, a platform KV service, Redis, or another backend. ## Separate values from invalidation [Section titled “Separate values from invalidation”](#separate-values-from-invalidation) Storage answers “do I have bytes for this key?” Invalidation answers “are those bytes still legal to serve?” Astilba Cache keeps those questions separate. | Component | Responsibility | | ---------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Registry** | The authoritative record of soft and hard invalidation watermarks. Purge methods write to it; strong stored-entry reads check it live. | | **Bus** | The fast delivery path for ordered invalidation events to active cache instances. A reset or gap makes local knowledge suspect; an explicit lost event records channel reachability separately. | | **Replication mirror** | Durable L2 pointer, delta, and snapshot objects that let a suspect instance recover invalidation changes it missed on the Bus. | | **Recovery reader** | Replays contiguous deltas first, then can use the pointer-blessed snapshot as a recovery floor when a persistent hole exhausts the delta retry budget. It remains fail closed if the chain cannot be established. | | **Replication poller** | Drives baseline mirror observation and bounded recovery retries outside foreground value work. The Workers factory supplies read-triggered ticks, React Router can adopt request-entry ticks, and raw runtimes need an equivalent driver. | The Bus is not the authority. It transports updates; the Registry and verified local or mirror history establish what is known. Coordinated read validation is built only when Registry, Bus, and L2 are all available. `createCache()` rejects a Registry-plus-Bus configuration without L2 because that reader would have no safe recovery mirror. ## Know the injected capabilities [Section titled “Know the injected capabilities”](#know-the-injected-capabilities) | Capability | Why it exists | Do basic callers usually choose it? | | ------------------ | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------- | | **Store** | Reads and writes encoded values or replication objects. | Use `memory()` locally or receive a driver from a runtime integration. | | **Clock** | Supplies logical time without hard-coding a platform clock into the portable kernel. | The current API requires it; a runtime preset should normally provide it. | | **Rng** | Supplies randomness through an explicit, testable source. | The current API requires it; a runtime preset should normally provide it. | | **Codec** | Encodes values and identifies their wire format before decoding. | Only when the default JSON round trip is insufficient. | | **Lock** | Coordinates opted-in fills across instances and supplies a fencing token. | Only for cross-instance contention. | | **Cdn** | Accepts shared HTTP-cache purge work. | The contract exists, but no implementation is wired today. Response-tag emission does not call it. | | **Telemetry sink** | Receives cache events; hosted mode pseudonymizes string fields with a project salt. | Only when operating or observing Cache. | Clock and Rng exist for portability and deterministic tests. They are construction details, not concepts application code should need on every read. ## Add components progressively [Section titled “Add components progressively”](#add-components-progressively) | Configuration | What it gives you today | | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clock` + `rng` + `l2` | Basic read, fill, and reuse without coordinated invalidation. The source walkthrough uses `memory()` for this preview-only shape. | | Add `l1` | Process-local reads and retention for values that cannot be shared. | | Add `registry` | Enables the purge methods, but does not create coordinated read validation by itself. | | Add `registry` + `bus` alongside the existing `l2` | Live invalidation delivery, channel-state tracking, coordinated validation, snapshot-capable mirror recovery, and an attached replication poller. The embedding runtime or adapter must supply a tick driver for background recovery; reads still perform reactive recovery without one. | | Add `lock` | Lets individual calls opt into cross-instance fill exclusion. | | Use React Router render collection | Automatically records served Cache entries, rejects non-public response dependencies, and emits eligible user tags. | | Add `cdn` | Declares the L3 purge boundary; the current implementation does not drive it. | | Use `createWorkersCache()` | Composes memory L1, KV L2, lazily addressed Coordinator Registry, tick-redialed Bus, and a read-triggered recovery carrier with Workers defaults. | ## Distinguish policy terms [Section titled “Distinguish policy terms”](#distinguish-policy-terms) * **TTL** is how long a value should be fresh by elapsed time. Duration values and the `duration()` builder are implemented, but the cache does not enforce elapsed TTL yet. * **Grace** is the period in which an eligible stale value may be reused after a classified transient failure. Its duration is not enforced yet. * A **negative entry** remembers an opted-in 404 without inventing a value. An invalidation-fresh negative resolves as `undefined`; it is never served through grace or stale-on-error. Before writing one, Cache checks L2 and refuses when it finds a decodable value whose invalidation verdict is fresh or stale, or cannot be established. Its declared duration is not enforced yet. * **Eventual consistency** uses verified local invalidation knowledge and fails closed when that knowledge is insufficient. * **Strong consistency** performs a live Registry check before serving a stored entry and before running the factory for a strong miss when coordinated invalidation is active. * **Soft invalidation** makes an older value stale. * **Hard invalidation** makes an older value unreadable. * **Singleflight** lets compatible callers in one instance share one foreground factory execution. * **Fencing** prevents a result produced across a conflicting hard invalidation from being accepted as current. The current fill path can re-mint its birth epoch and refetch within a bounded three-attempt budget when verified knowledge advances. Continue with the [source walkthrough](/docs/cache/quickstart/) for a concrete read, [Cloudflare Workers](/docs/cache/cloudflare-workers/) for the composed runtime, [Cache HTTP responses](/docs/cache/response-caching/) for L3 collection, [How Cache works](/docs/cache/how-it-works/) for the complete sequence, or [runtime architecture](/docs/cache/architecture/) for the capability contracts. # Cloudflare Workers > Compose the current Cache source with Cloudflare KV, a Coordinator Durable Object, and the Workers factory. The current source exposes a Cloudflare entry point at `@astilba/cache/cloudflare`. Its `createWorkersCache()` factory combines the portable kernel with a bounded memory L1, Cloudflare KV L2, a Coordinator Durable Object, a live WebSocket Bus, and request-driven recovery behavior. Implemented in source, not released The subpath is part of the reviewed source snapshot and publish configuration, and its primary path runs under workerd integration tests. The package is still absent from npm, elapsed TTL is incomplete, and the deployment has not completed its production-measurement and release gates. ## See what the factory owns [Section titled “See what the factory owns”](#see-what-the-factory-owns) You provide one stable registry name and two Cloudflare bindings: cache.server.ts ```ts import { env } from "cloudflare:workers" import { createWorkersCache } from "@astilba/cache/cloudflare" export const cache = createWorkersCache({ name: "storefront", kv: env.CACHE_KV, coordinator: env.COORDINATOR, telemetry: (event) => console.info(event), }) ``` Create this instance once at module scope. Construction performs no I/O: the factory captures the Coordinator namespace and name as an address recipe, then mints a request-scoped stub each time Registry or Bus work needs one. Retention registration, the first Bus dial, polling, and any later redial happen lazily from request activity. The Worker entry must separately export the Durable Object class so Wrangler can bind it: worker.ts ```ts export { Coordinator } from "@astilba/cache/cloudflare" export default { async fetch(): Promise { return new Response("Worker ready") }, } satisfies ExportedHandler ``` The `name` is one identity, not a display label. The factory uses it as: * the named Coordinator Durable Object address; * the Registry and replication-mirror identifier; * the Cache namespace used in canonical keys. Keep it stable for the lifetime of the cache domain. Changing it addresses a different Durable Object and a different keyspace. The factory also supplies: * a Workers wall-clock `Clock` and random `Rng` at the platform boundary; * `memory({ clock, maxEntries: 512, maxBytes: 5_000_000 })` as L1; * `cloudflareKV()` as L2; * `doRegistry()` with a thunk that mints a Coordinator stub per use; * a `doBus()` connection whose backed-off redials are performed by request-time ticks rather than timers; * a carrier on `getOrSet()` and `getOrSetEntry()` that drives the poller and any due Bus redial at most once per second without awaiting that work; * eventual consistency, live Registry checks for unknown knowledge, the default HTTP retry classifier, and a 30-second reader heartbeat interval unless you override those fields in `defaults`. The only required configuration fields are `name`, `kv`, and `coordinator`. Optional `defaults` override policy, `telemetry` observes kernel and carrier events, and `takedownSensitive` makes unknown invalidation knowledge throw rather than refill. The factory’s internal memory L1 does not have a separate telemetry option for `private_evicted`. ## Configure the Worker bindings [Section titled “Configure the Worker bindings”](#configure-the-worker-bindings) The Worker must export `Coordinator`, bind that class as a SQLite-backed Durable Object, and make one KV namespace visible under two binding names: wrangler.jsonc ```jsonc { "$schema": "./node_modules/wrangler/config-schema.json", "name": "storefront-worker", "main": "src/worker.ts", "compatibility_date": "2026-07-15", "compatibility_flags": ["nodejs_compat"], "durable_objects": { "bindings": [ { "name": "COORDINATOR", "class_name": "Coordinator" } ] }, "exports": { "Coordinator": { "type": "durable-object", "storage": "sqlite" } }, "kv_namespaces": [ { "binding": "CACHE_KV", "id": "" }, { "binding": "REGISTRY_KV", "id": "" } ], "vars": { "REGISTRY_HEARTBEAT_MS": "30000" } } ``` Both KV bindings point to the same namespace. `CACHE_KV` is the L2 Store the reader sees; `REGISTRY_KV` is where the Coordinator writes replication pointers, deltas, and snapshots. If they point at different namespaces, the reader cannot recover from the mirror the Coordinator produced. New deployments should declare the class lifecycle in the top-level `exports` map. Cloudflare still supports the older `migrations` array with `new_sqlite_classes`, and current internal fixtures may still use it, but the two forms are mutually exclusive. Follow Cloudflare’s [Durable Object class exports guide](https://developers.cloudflare.com/durable-objects/reference/durable-objects-migrations/) before adapting an existing deployment. The `nodejs_compat` flag is required because the root package uses `node:crypto`. Use a compatibility date of 2024-09-23 or later, as required by Cloudflare’s [Node.js compatibility guide](https://developers.cloudflare.com/workers/runtime-apis/nodejs/). The flag also supplies the AsyncLocalStorage support used by the React Router adapter. The narrower `nodejs_als` flag alone is not sufficient to boot the package. ## Decide whether to enable idle heartbeats [Section titled “Decide whether to enable idle heartbeats”](#decide-whether-to-enable-idle-heartbeats) `createWorkersCache()` configures the reader to expect a 30-second heartbeat interval. The Coordinator cannot set its own deployment variables, so you must separately opt its idle heartbeat in with: ```jsonc "vars": { "REGISTRY_HEARTBEAT_MS": "30000" } ``` Leaving the variable unset keeps idle heartbeats dormant. That avoids a recurring Durable Object alarm and KV write for every idle registry, but a reader can become conservatively suspicious and pay for live checks until it reconverges. Setting it to `30000` matches the factory’s reader default. If you override `defaults.heartbeatInterval`, update the deployment variable deliberately as well. The two settings are not synchronized by the library. ## Use the cache in a Worker [Section titled “Use the cache in a Worker”](#use-the-cache-in-a-worker) Once constructed, application code uses the same portable API: load-product.ts ```ts import { t } from "@astilba/cache" import { cache } from "./cache.server" export async function getProduct(productId: string) { return cache.getOrSet({ key: `product:${productId}`, tags: [t`product:${productId}`], factory: ({ signal }) => loadProduct(productId, signal), }) } ``` After updating the source of truth, invalidate the same dependency: ```ts await saveProduct(productId, input) await cache.delete({ tag: t`product:${productId}` }) ``` The mutation reaches the authoritative Coordinator. Active isolates receive live Bus events; suspect readers can recover through the KV mirror. A strong read performs a live check before serving a stored entry and before filling a strong miss. Cloudflare KV failures use the same classified Store boundary as the kernel. A classified KV read failure emits `store_read_suppressed` and behaves as a miss for that tier. The factory’s memory L1 absorbs the outage for values it already holds and for successful refills; without an L1, a sustained L2 outage could force every call back to origin. ## Understand the local chaos evidence [Section titled “Understand the local chaos evidence”](#understand-the-local-chaos-evidence) The reviewed source snapshot includes an unpublished React Router v8 app under `apps/demo`. It is not hosted or publicly downloadable. The app runs `createWorkersCache()` against local KV and Coordinator bindings, then places demo-owned wrappers between those bindings and the library. Cache itself has no fault-injection API. The app contains three scenes: | Scene | Injected fault | What to inspect | | ----------------------------------------------------------- | ------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Backend kill | The demo KV wrapper rejects every read and write. | A cold or missing L1 is required to exercise the failing KV path; a warm L1 can bypass KV. In an internal run, use a cold key or empty local state, or rely on the wrapper counters. Classified L2 reads become observable misses, successful origin work can refill L1, suppressed write-back remains non-durable, and `explain()` distinguishes `read-failed` from absence. | | Bus drop | The demo refuses future Coordinator WebSocket dials. | A reader created while the fault is armed reports `never-established` and emits `bus_dial_failed`. The fault does not sever an existing socket. A purge issued by the same reader is learned from its own acknowledgement, so the scene does not mislabel it as cross-isolate polling rescue. | | Bus and mirror loss (labelled “Registry outage” in the app) | The demo refuses future Bus dials and makes replication-mirror key reads fail. | Live Registry RPC remains available, and an already-established socket may remain connected. In the Vite development rig the channel was already `never-established`, so the scene removes both warm invalidation inputs there. Unknown knowledge follows the configured fail-closed posture and may pay for a live Registry check. | The Vite development rig uses `@cloudflare/vite-plugin`; the built rig launches the generated Worker through Wrangler. Both use Cloudflare’s local Workers tooling and run Worker code in workerd through Miniflare. In the reviewed local evidence, the WebSocket hello did not complete in the Vite development rig, while the built Wrangler rig established the channel. Treat that as an observed rig difference, not a claim that Vite runs outside workerd. A required source CI lane now holds the built-rig claim. It builds the demo, boots the emitted Worker with its real local Coordinator and KV bindings, and reads the channel from a narrow JSON status route rather than scraping page markup. The status reports the armed scene, channel, dial-failure count, event names, and any witness error. After a boot budget of 90 seconds, each channel arm polls that status every 500 milliseconds for at most 30 seconds; the scene action has its own 30-second budget. The healthy arm must reach `established`. The Bus-drop fault is then armed before the app creates a fresh scene-owned reader whose dial is refused. That armed arm must report `never-established` plus at least one `bus_dial_failed` event. It is a negative control: it proves the status instrument can distinguish a refused connection from a healthy one. A non-200 response, malformed status, witness error, stalled body, or budget that expires during a read is a probe failure and says nothing about the channel. If completed status reads continue until the budget ends without reaching the required state, the lane instead records `not-within-budget`: a channel verdict and a failed arm. The lane proves that the composed demo as built can expose those two states on local workerd. It proves neither Cache kernel semantics nor Bus-mechanism correctness; the invariant and integration lanes own those questions. It also proves no propagation latency, production availability, or deployed SLO. Deployed probes remain absent. The internal app reduces `maxSyncLag` from the factory’s 60-second baseline to five seconds so request-driven polling is watchable locally. It prints what happened beside what the configuration permits and asserts no timing or consistency SLO. No deployed measurements have been taken; those remain a release gate. ## Know the operational boundary [Section titled “Know the operational boundary”](#know-the-operational-boundary) The source path currently includes: * KV value-size rejection and write-failure classification; * Coordinator command journaling, coalesced flushes, snapshots, and Registry RPC; * WebSocket Bus delivery with scope checks, explicit lost-channel reporting, and tick-driven jittered redial backoff; * reactive read-path recovery plus an out-of-band polling state machine; * a factory-owned request-driven carrier for plain Worker reads; * optional React Router lifecycle adoption of middleware ticks through `waitUntil`; * the source-only three-scene chaos evidence app described above; * the required composed-demo workerd boot witness and its armed negative control. It does not yet provide: * an npm release or supported upgrade policy; * elapsed TTL, grace, or negative-entry expiry enforcement—entry age is measured for observability but does not enforce policy; * journal checkpointing and truncation for a long-lived Coordinator; * a production Lock or CDN purge driver; * an end-to-end CDN purge path, even though the React Router adapter now emits safe `Cache-Tag` headers; * deployed consistency, propagation, caching, and production-threshold measurements that complete the Workers release path. Continue with [React Router](/docs/cache/react-and-server-apps/) if that is your server framework, [Cache HTTP responses](/docs/cache/response-caching/) for the response-tag safety model, [Driver implementations](/docs/cache/drivers-and-status/) for component-level status, or [Implementation status](/docs/cache/api-status/) for kernel limitations. # React Router > Provide Astilba Cache to React Router v8 loaders and actions while carrying server request identity safely. The current source exposes server middleware at `@astilba/cache/react-router`. It puts a Cache instance into React Router’s typed request context, carries an application-derived identity frame, adopts background recovery work into the request lifecycle, and turns value-cache dependencies into scope-safe response tags. Server adapter, source preview This is a React Router v8 server integration. It is not a browser cache, a client data-fetching library, or a released package. The source adapter has unit and real Vite build coverage, but the npm package and production support policy do not exist yet. ## Put Cache on the server [Section titled “Put Cache on the server”](#put-cache-on-the-server) | Code location | Use Astilba Cache? | Why | | -------------------------------------------- | ------------------------------------------- | -------------------------------------------------------------------------- | | Server loader or action | Yes, through the middleware | The factory and invalidation call stay on the server. | | Server Component or another server framework | Use the portable Cache API | The current framework adapter is specifically for React Router v8. | | API route or backend service | Yes, through a runtime-owned Cache instance | Cache is ordinary server-side TypeScript. | | Client Component or browser-only SPA | No | Browser request state and component lifecycles need a client data library. | Build the Cache instance once for the server runtime. On Cloudflare, use `createWorkersCache()` as shown in [Cloudflare Workers](/docs/cache/cloudflare-workers/). That factory is safe at module scope: construction performs no I/O, and Coordinator handles plus the Bus connection are acquired lazily inside requests. ## Register the root middleware [Section titled “Register the root middleware”](#register-the-root-middleware) React Router v8 enables middleware by default and removes the flag. A React Router v7 application must first enable `future.v8_middleware` as described in the [v7 upgrade guide](https://reactrouter.com/upgrading/v7#futurev8_middleware). This adapter targets v8; register `cacheMiddleware()` in the root route module as described by the current [middleware guide](https://reactrouter.com/how-to/middleware): root.tsx (Cloudflare Workers) ```tsx import { waitUntil } from "cloudflare:workers" import type { MiddlewareFunction } from "react-router" import { cacheMiddleware } from "@astilba/cache/react-router" import { authMiddleware, authenticatedUserContext } from "./auth.server" import { cache } from "./cache.server" export const middleware: MiddlewareFunction[] = [ authMiddleware, cacheMiddleware({ cache, waitUntil, request: ({ context }) => { const user = context.get(authenticatedUserContext) return { userId: user.id, tenant: user.tenantId } }, }), ] ``` The `request` mapper is synchronous. Derive identity from a session or typed context that earlier trusted server middleware has already validated; do not treat an arbitrary client header as an authenticated principal. If a request is intentionally anonymous, return `{}` or omit the mapper. With no visible principal and no explicit scope, the kernel resolves the call to the public storage class. ## Read in a loader [Section titled “Read in a loader”](#read-in-a-loader) Use `cacheContext` to obtain the request’s Cache instance. Pass `currentRequest()` into each read so the kernel can derive its privacy scope: routes/product.tsx ```tsx import type { Route } from "./+types/product" import { t } from "@astilba/cache" import { cacheContext, currentRequest, } from "@astilba/cache/react-router" export async function loader({ context, params }: Route.LoaderArgs) { const cache = context.get(cacheContext) const productId = params.productId const product = await cache.getOrSet({ key: `product:${productId}`, tags: [t`product:${productId}`], request: currentRequest(), factory: ({ signal }) => loadProduct(productId, signal), }) return { product } } ``` `currentRequest()` returns the frame opened by the root middleware. Outside that frame it returns `undefined`. Calling `context.get(cacheContext)` without installing the middleware throws instead of silently constructing another cache; `cacheContext` follows React Router’s [no-default `createContext()` behavior](https://reactrouter.com/api/utils/createContext). After a mutation, change the source of truth first and invalidate through the same request Cache: routes/product-update.ts ```ts export async function action({ context, params, request }: Route.ActionArgs) { const cache = context.get(cacheContext) const productId = params.productId const input = await parseProductUpdate(request) await saveProduct(productId, input) await cache.delete({ tag: t`product:${productId}` }) return { ok: true } } ``` ## Enable Workers compatibility [Section titled “Enable Workers compatibility”](#enable-workers-compatibility) The adapter uses `AsyncLocalStorage` to make `currentRequest()` available throughout the request’s async call tree. The root package also uses `node:crypto`, so Cloudflare Workers must enable: wrangler.jsonc (merge into your existing config) ```jsonc { // Minimum for nodejs_compat v2; use your project's current date. "compatibility_date": "2024-09-23", "compatibility_flags": ["nodejs_compat"] } ``` `nodejs_compat` supplies both requirements when the Worker’s compatibility date is 2024-09-23 or later, as required by Cloudflare’s [Node.js compatibility guide](https://developers.cloudflare.com/workers/runtime-apis/nodejs/). The narrower `nodejs_als` flag alone does not provide `node:crypto` and cannot boot the package. ## Keep recovery work off the response path [Section titled “Keep recovery work off the response path”](#keep-recovery-work-off-the-response-path) At request start, the middleware asks the cache’s replication poller whether a tick is due. It does not await that work before running loaders. Passing Cloudflare’s `waitUntil` function allows an in-flight tick to continue after the response returns; Cloudflare documents that lifecycle in its [Context API guide](https://developers.cloudflare.com/workers/runtime-apis/context/#waituntil). The adapter limits ticks to at most one per second per Cache instance. The poller’s longer baseline, retry, and backoff schedules still decide whether a tick performs I/O. A failed tick is swallowed so it cannot fail the user’s response; provide a `telemetry` sink if you need to observe `poll_tick_failed` events. `onSinkError` observes a telemetry sink that throws or rejects without allowing that failure to escape either. `createWorkersCache()` also starts a best-effort recovery tick from value reads, so plain Workers applications recover without React Router. The middleware remains useful because it starts work at request entry and can adopt it with `waitUntil`. Neither path awaits recovery before continuing the user’s request. Without `waitUntil`, the tick is still started but is only best effort after the response lifecycle ends. Recovery does not become unsafe—the read path remains fail closed—but future requests may pay more live-check or refill work. ## Understand the response-cache posture [Section titled “Understand the response-cache posture”](#understand-the-response-cache-posture) The middleware opens a request-scoped render collector around `next()`. Cache hits and successful fills automatically contribute their complete stored tag set and scope evidence. After the render, the middleware commits that collector against `L3_BUDGET_DEFAULT`—16 KB and 1,000 tag occurrences unless `l3Budget` overrides it. The application still decides whether to opt into shared caching. The middleware never writes `public` or `s-maxage`: | Render result | Header behavior | | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | | No managed Cache dependency | Preserve existing cache headers; add `Cache-Control: private` only when the application supplied no policy. | | Eligible public dependencies | Preserve the application’s cache policy and replace `Cache-Tag` with deduplicated user tags. If no policy exists, default to `private`. | | Private, unreadable, late, or over-budget dependency | Force `Cache-Control: private`, remove `Cache-Tag`, and emit one `l3_ineligible` event. | Reserved per-key and per-namespace tags never reach the response header. One tenant- or principal-scoped dependency makes the whole response private, even if its tag was marked droppable. Responses with immutable header guards are rebuilt so redirects and fetch-derived responses do not turn into middleware errors. Pass the request frame to reads Automatic render collection does not remove the need for `request: currentRequest()`. That option lets the kernel resolve principal and tenant scope. Omitting it from identity-bearing reads can make the value appear contextless and public. The adapter exports `L3_BUDGET_DEFAULT` and `L3_INELIGIBLE` alongside the poll constants. `CacheMiddlewareOptions` accepts `l3Budget`, `telemetry`, and `onSinkError` in addition to `cache`, identity mapping, and `waitUntil`. The response-tag path does not purge a CDN. See [Cache HTTP responses](/docs/cache/response-caching/) for the complete safety and budget model, [Control cache sharing](/docs/cache/scopes-and-privacy/) for value storage, [Consistency and resilience](/docs/cache/consistency-and-resilience/) for recovery behavior, and [Implementation status](/docs/cache/api-status/) for current gaps. # Read and cache values > Choose between the simple value API and the metadata-rich entry API. Astilba Cache provides two read APIs. Use `getOrSet()` when you only need a value. Use `getOrSetEntry()` when the caller needs cache metadata or the factory may intentionally skip storage. If terms such as factory, L1, L2, origin, or entry are new, see [Cache fundamentals](/docs/cache/core-concepts/). ## Choose the return shape [Section titled “Choose the return shape”](#choose-the-return-shape) ### Return a value [Section titled “Return a value”](#return-a-value) ```ts const product = await cache.getOrSet({ key: `product:${productId}`, tags: [compound("product", productId)], factory: async ({ signal }) => loadProduct(productId, signal), }) ``` The factory receives a `FactoryCtx` without `skip()`. A skipped result therefore cannot be mistaken for a successful `T`. ### Return an entry [Section titled “Return an entry”](#return-an-entry) ```ts const entry = await cache.getOrSetEntry({ key: "optional-banner", factory: async (ctx) => { if (!shouldRenderBanner()) return ctx.skip() return loadBanner() }, }) if (!entry.skipped && entry.value !== undefined) { render(entry.value) } ``` `getOrSetEntry()` returns these fields: | Field | Meaning in the current kernel | | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `value` | The stored, filled, or stale value; `undefined` for a miss, skip, or negative entry. | | `tier` | `l1`, `l2`, `origin`, or `miss`. `l1.5` exists in the type but is not emitted by the current implementation. | | `stale` | The returned value was not fresh at this read’s consistency level. | | `servedOnError` | A classified transient failure reused a stale candidate after serve-time revalidation. | | `durable` | Optional evidence from this serve. Every newly filled origin result reports `true` or `false`; an L2 hit reports `true`; a principal-scoped L1 hit reports `false`. A public or tenant L1 hit omits the field because that serve does not consult L2. | | `skipped` | The entry-form factory called `ctx.skip()`; nothing was stored. | | `age` | Whole milliseconds since the served envelope’s `bornMs` fill-start timestamp, measured by the injected Clock and clamped at zero. A miss or skip reports zero. | Every newly filled origin result includes `durable`. A `false` value does not mean the factory failed: it can mean the value was confined to L1 by scope, no L1 existed to retain that private value, a classified transient L2 write failure was suppressed, or an attempted negative was refused to protect a value already in L2. Only a later hit can omit `durable`; that absence is different from `false` because the current serve cannot prove either outcome. Treat `entry.durable === true` as positive evidence and compare explicitly with `false` only when you need to distinguish known non-durability from an unknown answer. ## Follow the fill lifecycle [Section titled “Follow the fill lifecycle”](#follow-the-fill-lifecycle) 1. **Read configured tiers.** Cache tries L1 before L2. It checks the stored codec identity before decoding, then validates the reconstructed entry. 2. **Join compatible work.** Concurrent compatible calls share one in-isolate foreground factory execution. 3. **Run the factory.** The factory receives an `AbortSignal`, optional request context, and typed failure and dependency helpers. The current kernel creates a fresh signal but does not yet abort it on a cache deadline. 4. **Close the tag set.** When the factory settles, Cache uses the latest `setTags()` base—or the call-level tags when no replacement was authored—and unions every `dependsOn()` membership into one validated set. 5. **Fence the result.** A hard invalidation observed during the fill can reject write-back. When verified invalidation knowledge advanced, the kernel can re-mint the birth epoch and refetch within a bounded three-attempt budget. 6. **Write by scope.** Shared scopes may reach L2; principal-derived values are L1-only. A successful fill hydrates L1 when one is configured. When the bounded attempts still leave no servable value, `getOrSet()` throws `FencedError`. `getOrSetEntry()` reports a non-durable miss instead. A Store read has its own failure boundary. A structurally classified `throttled` or `unavailable` read emits `store_read_suppressed` and behaves as a miss for serving, allowing another tier or the factory to supply the value. An unclassified read error propagates unchanged. L1 can therefore absorb an L2 outage for keys already present locally; without such an L1 hit, repeated calls may refill while L2 remains unavailable. Protect origin during an L2 outage The current kernel has no circuit breaker or origin-load backoff for suppressed Store reads. Before production use, applications must provide their own origin concurrency limits, load shedding, and alert thresholds, and should retain the bounded L1 supplied by the Workers factory. The package remains unreleased partly because these operating thresholds have not yet been established through deployed measurements. A strong, coordinated miss live-checks the canonical key and namespace tags plus the caller-declared tags before the first factory attempt. That check anchors the fill at the Registry’s current global epoch before origin work begins. Factory-discovered tags join the final stored set and the write-back fence; a hard purge delivered for one of them during the fill can fence and retry the result, even though that tag was not individually available to the pre-factory check. ## Declare dependencies during the factory [Section titled “Declare dependencies during the factory”](#declare-dependencies-during-the-factory) Use factory helpers when the origin result reveals dependencies that the caller could not know beforehand: ```ts const article = await cache.getOrSet({ key: `article:${articleId}`, tags: [t`articles`], factory: async (ctx) => { const article = await loadArticle(articleId, ctx.signal) ctx.setTags([t`articles`, compound("article", article.id)]) ctx.dependsOn(compound("author", article.authorId)) return article }, }) ``` `setTags()` replaces the call-level `tags` base; only its most recent call is the authored base. `dependsOn()` adds memberships independently, so calling it before or after `setTags()` produces the same union. Cache stores that final set, uses it for later invalidation, and contributes it to an active React Router render collector. The combined set may contain at most 126 distinct user tags. Invalid, reserved, or excessive factory tags fail the fill before storage. A stashed context cannot mutate a completed value: calling `dependsOn()` or `setTags()` after the factory promise settles throws `FactorySettledError`. `FactoryCtx.dependsOn(tag, { l3: false })` currently throws `NotImplementedError`; the stored format cannot retain that per-tag response-emission flag for later hits. `FactoryCtx.setTtl()` also throws `NotImplementedError` while elapsed TTL is deferred. ## Cache an explicit 404 [Section titled “Cache an explicit 404”](#cache-an-explicit-404) Set `notFoundTtl` only when an `HttpError` with status 404 is a cacheable fact: ```ts import { httpError } from "@astilba/cache" const entry = await cache.getOrSetEntry({ key: `product:${productId}`, notFoundTtl: "30s", factory: async () => { const response = await fetchProduct(productId) if (response.status === 404) throw httpError(response) if (!response.ok) throw httpError(response) return response.json() }, }) if (entry.tier === "miss") { throw new Error("Cache could not establish a result") } if (entry.value === undefined) { return new Response("Not found", { status: 404 }) } ``` Check `tier` before interpreting `undefined`: the entry form also uses `{ tier: “miss”, value: undefined }` for a terminal fenced fill, and `skip()` produces a skipped miss. Neither is a 404 fact. When serve-time validation completes and the factory-running caller’s own stale candidate does not suppress the 404, Cache attempts one negative L2 write and the call resolves with `value: undefined` at `tier: “origin”`. Before writing, the L2 negative-write guard checks the current entry in that same Store with the serving path’s codec and invalidation rules. A decodable value that is fresh or stale refuses the negative; Cache also refuses when its invalidation verdict cannot be established. The origin result then reports `durable: false`, the existing L2 value remains stored for later reads to re-evaluate, and `neg_suppressed` is emitted. The guard is deliberately Store-local and does not inspect L1. Codec-incompatible, undecodable, or dead L2 bytes do not trigger it; normal newer-envelope arbitration still applies afterward. The current Store contract also makes this a read-then-write check rather than an atomic compare-and-set, so a competing write can interleave after the guard’s read. A classified retryable write failure is a separate `durable: false` outcome. An accepted negative can supply a later invalidation-fresh L2 hit; that hit also surfaces `undefined`, while retaining the tier, age, and durability evidence for the entry actually read. `notFoundTtl` still does not expire the entry by elapsed time. The internal storable placeholder is never exposed as an application value. The plain `getOrSet()` form also resolves `undefined` when an opted-in 404 takes the negative disposition. Its current declaration remains `Promise`; this is a type limitation, not proof that the result is defined. Use an explicit union such as `getOrSet(…)` whenever you enable `notFoundTtl`, or use `getOrSetEntry()` and branch on both `entry.tier` and `entry.value`. Negative entries are stored only in L2. A negative result that reaches the serve path skips L1 hydration and makes a best-effort attempt to delete an older L1 value for the same canonical key unless the L2 negative-write guard refused the write. That cleanup still runs after a classified retryable L2 write failure. Permanent or unclassified Store failures propagate before the result reaches this step. If the delete fails, the call still returns the negative fact, but the older L1 entry can be served by a later read until the tier evicts it or a later value fill overwrites it. A guard refusal leaves both tiers unchanged. ## Compatible concurrent calls [Section titled “Compatible concurrent calls”](#compatible-concurrent-calls) Singleflight joins calls only when their canonical key and structural settings agree. Tags, TTL, grace, negative-cache TTL, resolved scope, codec identity, consistency, and API form all participate. Tag order does not matter because tags are sorted and deduplicated first. The first compatible call runs the shared factory; later compatible calls wait for that work. A successful fill, or a stale serve already revalidated by the factory-running call, is shared with the metadata for the served entry. If that call had no stale candidate and the factory produced only a classified transient failure, each waiting caller makes its own stale-on-error decision using the candidate it read before joining. The factory-running call can receive the origin error while a waiting caller with an eligible stale value revalidates and serves its own copy. A hard invalidation that lands before that revalidation still prevents the stale serve. When serve-time validation completes, an opted-in shared 404 has three served dispositions: * If the factory-running caller declared `grace` and holds a still-servable stale value, it suppresses the negative write and serves that value. Every compatible joiner inherits the leader’s value and evidence, even if that joiner did not read a candidate of its own. * If that grace-eligible stale candidate revalidates as dead or unknown, the leader makes the negative write attempt and serves that result. Every compatible joiner inherits the negative result; a candidate that cannot be established servable is not eligible for caller-local fallback. * If the factory-running caller has no grace-eligible candidate, it makes the negative L2 write attempt once inside the singleflight window and shares the not-found fact. Each waiter then compares that fact with its own earlier read. A waiter that declared `grace` and observed a still-servable stale value returns that value without `servedOnError`; a waiter without a servable stale candidate returns `undefined`. When `grace` is absent from the compatible calls, every caller takes the negative disposition even if it observed a stale value. Every negative attempt above passes through the same L2 negative-write guard. A refusal changes storage and durability evidence, not the shared not-found disposition: callers taking that disposition still receive `undefined`, while the existing L2 value remains stored. Serve-time validation may instead throw `RegistryUnavailableError` under the configured unavailable posture. Waiters never repeat or rewrite the negative entry. For classified transient failures, Cache consults `defaults.staleIfError` only for a call that declares `grace`. Without grace, no caller can use a stale candidate as transient-error fallback; that origin error propagates without invoking the application’s classifier. Opted-in 404 handling is the separate fact path described above and can resolve a negative result without grace. This does not change soft-stale eventual refresh: that separate path may still return its stale value as described below. With `dev: true`, an incompatible same-key call fails loudly. Otherwise it runs separately and emits `singleflight_option_mismatch` telemetry. ## Codec changes become misses [Section titled “Codec changes become misses”](#codec-changes-become-misses) Stored values carry a codec identity. Cache checks that identity before decoding, so an unexpected codec becomes a miss instead of a mistyped value. Intentional migrations can allow selected older identities through `defaults.acceptCodecs`. The built-in codec is a plain JSON round trip. Use JSON-representable values only: it does not revive dates, classes, functions, or bigint values. Supply a custom `Codec` with a new identity when you need another wire format; if you accept an older identity, the current decoder must understand those older bytes. ## Current boundaries [Section titled “Current boundaries”](#current-boundaries) Time is not enforced yet Per-call and default TTL and grace values do not currently expire entries by elapsed time. Stored envelopes use zero TTL and grace fields, and a declared `notFoundTtl` opts into a negative write without enforcing the requested duration. Entry `age` is measured from the served envelope’s `bornMs` fill-start timestamp, but that observation does not make timing policy active. A soft-stale eventual read currently awaits a best-effort refresh, then still returns the stale value for that call. The planned background adoption and retry lifecycle is not implemented, so this path does not yet provide background stale-while-revalidate latency. `ctx.graced` is not populated and `reuseGraced()` throws `NotImplementedError`. Use the documented stale-on-error path instead of factory-directed grace reuse. ## Related [Section titled “Related”](#related) * [Source walkthrough](/docs/cache/quickstart/) shows both value reads against the implemented memory Store used as a development-only L2. * [Cache fundamentals](/docs/cache/core-concepts/) explains the storage tiers and read vocabulary. * [Consistency and resilience](/docs/cache/consistency-and-resilience/) explains when stale values may be reused. * [Cache HTTP responses](/docs/cache/response-caching/) explains how served and factory-declared tags reach a response collector. * [Inspect cache behavior](/docs/cache/observability/) shows how to witness the stored final tag set. * [Implementation status](/docs/cache/api-status/) lists provisional metadata and unimplemented helpers. # Invalidate cached data > Mark dependent values stale, make them unreadable, or invalidate one contextless public key. In Astilba Cache, tags describe what a value depends on. Invalidation advances tag watermarks, so readers can reject matching entries without scanning or deleting every stored value. The application changes its source of truth; Cache only changes whether stored representations may be served. See [Cache fundamentals](/docs/cache/core-concepts/) for keys, tags, Registry, and Bus. ## Choose soft or hard invalidation [Section titled “Choose soft or hard invalidation”](#choose-soft-or-hard-invalidation) | Operation | Effect | | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | | `expire()` — soft | Values born before the new soft watermark become stale. An eventual read may return the old value after attempting a refresh for a later read. | | `delete()` — hard | Values born before the new hard watermark become unreadable wherever that invalidation is visible. Grace cannot resurrect them. | invalidation.ts ```ts const productTag = compound("product", productId) // Mark every entry carrying the tag stale. await cache.expire({ tag: productTag }) // Make every entry carrying the tag unreadable. await cache.delete({ tag: productTag }) ``` Change the source first Invalidating a cached representation does not update or remove the underlying record. Apply the source-of-truth change before issuing a hard invalidation so a refill cannot reproduce the old value. ## Choose a tag builder [Section titled “Choose a tag builder”](#choose-a-tag-builder) Use `t` when the final tag is already in the public tag grammar: lowercase letters, digits, `:`, `_`, `-`, `.`, `|`, and `%`. It rejects an empty value, a reserved `__` prefix, off-grammar characters, and values over 256 UTF-8 bytes. ```ts const productTag = t`product:${productId}` ``` Interpolated values are not escaped. A space, slash, uppercase letter, or other off-grammar character throws `InvalidTagError` rather than being silently rewritten. Use `compound()` when positional structure matters. It escapes percent signs and the `:` and `|` delimiters, then prefixes the vector’s arity. Delimiter-like values, empty strings, and vectors of different lengths therefore remain distinct. ```ts const productTag = compound("product", productId) const categoryListingTag = compound("category", categoryId, "listing") ``` Current `compound()` validation boundary The current source encodes `%`, `:`, and `|` but does not run `t`’s remaining character or 256-byte validation over compound parts. Keep every part lowercase and grammar-safe, and keep the encoded result within the byte budget. This is a current implementation boundary, not permission to use arbitrary raw identifiers as tags. Caller-supplied tags on `getOrSet()` and `getOrSetEntry()` are rejected when they begin with `__`. The kernel reserves that prefix for its per-key and per-namespace tags. The `Tag` brand prevents ordinary raw-string selectors; do not bypass it with a type assertion. ## Add dependencies discovered by the factory [Section titled “Add dependencies discovered by the factory”](#add-dependencies-discovered-by-the-factory) Call-level `tags` are the initial dependency set. A running factory can refine that set: ```ts factory: async (ctx) => { const product = await loadProduct(productId, ctx.signal) ctx.setTags([compound("product", product.id)]) ctx.dependsOn(compound("category", product.categoryId)) return product } ``` `setTags()` authors a replacement for the call-level base. `dependsOn()` adds membership independently and is never erased by a later `setTags()`. At factory settlement, Cache deduplicates and validates the combined set, stores it with the entry, and uses it for invalidation and write-back fencing. The final entry may carry at most 126 distinct user tags, whether they came from the call or factory. Reserved tags that Cache adds internally do not consume that user allowance. Invalid, reserved, or excessive factory tags fail the fill before anything is stored. Dependency declaration closes when the factory promise settles. A later `dependsOn()` or `setTags()` call through a retained context throws `FactorySettledError`. `FactoryCtx.dependsOn(tag, { l3: false })` is also not implemented and throws; use the render collector’s `l3: false` option only for render-only dependencies. ## Invalidate by key carefully [Section titled “Invalidate by key carefully”](#invalidate-by-key-carefully) The key selector maps a user key to Cache’s reserved per-key tag: ```ts await cache.expire({ key: `product:${productId}` }) await cache.delete({ key: `product:${productId}` }) ``` In the current kernel, that selector resolves the **contextless public** canonical key: it has no request or scope input. It does not target principal-derived or tenant-scoped variants of the same user key. Use a dependency tag when data can exist in more than one scope. The selector types also expose `scope` on tag invalidation, but the current implementation does not apply it when resolving the Registry tag. Treat a tag purge as affecting every cached entry carrying that tag; do not rely on scope-qualified tag invalidation yet. ## Clear a namespace [Section titled “Clear a namespace”](#clear-a-namespace) `clear()` bumps the calling instance’s namespace version and issues a hard invalidation for the reserved namespace tag. The version makes old keys unreachable on that instance; the hard watermark makes other readers reject pre-clear entries as the invalidation reaches them. ## Understand the result [Section titled “Understand the result”](#understand-the-result) `expire()`, `delete()`, and `clear()` return a `PurgeResult` with an epoch, `matchedHint`, `flushed()`, and `edgePurged()`. In the current preview: * `matchedHint` is always `“unknown”`; * `flushed()` resolves immediately without measuring mirror acceptance; * `edgePurged()` resolves immediately without invoking a CDN queue; * the `cdn` option on `delete()` is not wired. Do not use those completion fields as rollout or takedown guarantees yet. ## Configuration boundary [Section titled “Configuration boundary”](#configuration-boundary) The purge verbs require a `Registry`. For reads to observe coordinated invalidation, configure Registry, Bus, and L2 together. L2 remains required for fills and lets suspect readers recover through durable deltas and snapshots. The source Workers factory wires the Coordinator Registry, tick-redialed Durable Object Bus, KV mirror, and request-driven recovery carrier. The CDN path and real purge-completion promises are still not implemented. When React Router render collection is active, a served entry’s stored user tags can also become a `Cache-Tag` response header. Scope and budget checks run before emission, and reserved tags are filtered. This response tagging does not make the CDN purge path operational; see [Cache HTTP responses](/docs/cache/response-caching/). ## Related [Section titled “Related”](#related) * [How Cache works](/docs/cache/how-it-works/) follows invalidation through live delivery and recovery. * [Consistency and resilience](/docs/cache/consistency-and-resilience/) explains how reads treat stale or unknown knowledge. * [Read and cache values](/docs/cache/reading-and-filling/) explains the complete factory lifecycle. * [Cache HTTP responses](/docs/cache/response-caching/) distinguishes stored dependencies from response emission. * [Implementation status](/docs/cache/api-status/) records the current purge-result and selector limitations. # Control cache sharing > Keep identity-bearing values local while allowing deliberate public and tenant sharing. In Astilba Cache, scope answers a storage question: may this value leave the current isolate and enter a shared tier? L1 is local to one process or worker isolate; L2 is shared or durable. The Workers factory supplies a bounded `memory()` L1 automatically. See [Cache fundamentals](/docs/cache/core-concepts/) for the complete storage vocabulary. ## Follow the resolution rules [Section titled “Follow the resolution rules”](#follow-the-resolution-rules) | Inputs | Resolved storage class | Current behavior | | ------------------------------------------ | ---------------------- | ------------------------------------------------------------------------------------- | | `scope: “public”` | `pub` | Eligible for shared L2, subject to the development request guard below. | | `scope: { tenant }` | Hashed `ten:` | Eligible for shared L2; the raw tenant identifier is not stored in the scope segment. | | No declared scope and a visible principal | Hashed `usr:` | L1-only and `durable: false`. | | No declared scope and no visible principal | `pub` | Eligible for shared L2. | The kernel derives a principal from primitive `request.userId` or `request.tenant` values, in that order. Other request fields do not affect automatic scope resolution. The React Router adapter carries the application-derived request object through `currentRequest()`; the application remains responsible for authenticating it. profile.ts ```ts const entry = await cache.getOrSetEntry({ key: "profile", request: { userId }, factory: async () => loadProfile(userId), }) entry.durable // false — principal-derived values never reach shared L2 ``` Durability is evidence from the current serve, so the field is optional on the overall result type. Every fresh fill reports `true` or `false`, an L2 hit proves `true`, and a later principal-scoped L1 hit proves `false`. A public or tenant value served from L1 omits `durable`, because the fast path does not consult L2 merely to answer whether another copy exists. ## Retain private values with L1 [Section titled “Retain private values with L1”](#retain-private-values-with-l1) A principal-derived fill still needs L2 to run in the current kernel, but the result deliberately skips the L2 write. Configure an L1 `Store` if you want the private value retained for a later call on the same isolate. Without L1, the current call succeeds with `durable: false` and the next call fills again. `createWorkersCache()` includes a bounded memory L1 for this reason. ## Treat public as a claim [Section titled “Treat public as a claim”](#treat-public-as-a-claim) With `dev: true`, Cache wraps `ctx.request` for an explicitly public factory. Reading any request property demotes that fill to L1-only storage. Merely attaching a request does not demote it; the factory must read through the guarded context. public-feed.ts ```ts const entry = await cache.getOrSetEntry({ key: "feed", request: { userId }, scope: "public", factory: async (ctx) => loadFeed(ctx.request?.userId), }) entry.durable // false in dev: the public factory read request data ``` This guard is a development aid, not closure analysis. It cannot see identity captured outside `ctx.request`, and production mode does not install the demoting Proxy. You remain responsible for making every explicit public or tenant cache key cover all inputs that can change the returned value. ## Make tenant sharing deliberate [Section titled “Make tenant sharing deliberate”](#make-tenant-sharing-deliberate) A request containing only `tenant` still has visible identity, so an undeclared scope becomes principal-derived and L1-only. Declare a tenant scope when values are intentionally shared inside one tenant. tenant-settings.ts ```ts await cache.getOrSet({ key: "settings", scope: { tenant: tenantId }, factory: async () => loadTenantSettings(tenantId), }) ``` Contextless work defaults to shared Queue consumers, cron jobs, and other contextless code resolve to the public storage class when no principal is visible. Declare a tenant or otherwise separate the key whenever that work caches identity-bearing data. ## Telemetry follows the same posture [Section titled “Telemetry follows the same posture”](#telemetry-follows-the-same-posture) A plain telemetry sink receives events as emitted and may contain raw identifiers. When `telemetry.hosted` is true and a project salt is supplied, the kernel HMAC-pseudonymizes every string field except the structural event type. A hosted configuration without a salt suppresses events rather than forwarding raw strings. Built-in delivery also swallows a sink that throws or rejects; configure `onSinkError` to observe that failure separately. The `memory()` Store can emit `private_evicted` when an LRU entry with `usr:` scope is removed under entry or byte pressure. Its payload carries only a count and byte size—never a key, scope hash, or tag. `createWorkersCache()` accepts kernel telemetry, but does not expose a separate sink for its internal memory L1; raw composition is required to enable this event. ## Apply scope to rendered responses [Section titled “Apply scope to rendered responses”](#apply-scope-to-rendered-responses) Value scope also gates React Router response caching. The middleware records scope evidence for every served Cache entry and successful fill: * only readable `pub` dependencies remain eligible for `Cache-Tag` emission; * a tenant or principal dependency forces `Cache-Control: private` for the whole response; * missing or malformed stored scope also fails closed to private; * `l3: false` may suppress a tag, but it never suppresses the entry’s scope evidence. A bare `RenderCollector.dependsOn()` is different: it is not backed by a managed entry, so it has no scope claim and cannot poison the response by itself. See [Cache HTTP responses](/docs/cache/response-caching/) for the complete header algorithm. ## Related [Section titled “Related”](#related) * [Runtime architecture](/docs/cache/architecture/) shows how L1 and L2 fit into a configured cache. * [React Router](/docs/cache/react-and-server-apps/) shows how a server adapter carries authenticated identity into the request frame. * [Read and cache values](/docs/cache/reading-and-filling/) explains durability metadata and tier selection. * [Invalidate cached data](/docs/cache/tags-and-invalidation/) covers the limits of key and scope-qualified selectors. * [Cache HTTP responses](/docs/cache/response-caching/) explains how value scope controls shared-response eligibility. * [Inspect cache behavior](/docs/cache/observability/) covers scope evidence in `explain()` and telemetry. # Cache HTTP responses > Emit safe Cache-Tag headers from React Router renders without sharing private content. Astilba Cache can connect value-cache dependencies to a shared HTTP cache. During a React Router request, the middleware records every Cache entry the render consumes, checks whether all of those entries are public, and emits their user tags as a `Cache-Tag` response header when the result is eligible. Tagging is implemented; CDN purging is not The React Router collection and response-header path is implemented in the current source preview. The application still owns its `Cache-Control` policy, and Cache does not yet send tag purges to Cloudflare or another CDN. Do not treat `edgePurged()` as proof of an edge purge. ## Understand the division of responsibility [Section titled “Understand the division of responsibility”](#understand-the-division-of-responsibility) The adapter answers one question: **is it safe to associate this response with the dependencies Cache observed?** It does not decide that the response should be shared. | Responsibility | Owner | | -------------------------------------------------------------- | --------------------------------------------- | | Set `public`, `s-maxage`, or another shared-cache directive | Your application or framework response policy | | Record Cache hits and fills used by the render | Astilba Cache and the React Router middleware | | Reject a response that consumed non-public or unreadable scope | The middleware | | Assemble an eligible `Cache-Tag` header | The middleware | | Purge the CDN after `delete()` | Not implemented in the current source | The middleware never writes `public` or `s-maxage`. If your application sets no `Cache-Control`, it fills in `private`. To opt an eligible route into shared caching, set the final response policy yourself, for example: ```http Cache-Control: public, s-maxage=60 ``` That opt-in remains subject to the middleware’s safety gate. If the render later consumes a private dependency, the middleware overwrites the policy with `Cache-Control: private` and removes `Cache-Tag`. ## Let the middleware collect dependencies [Section titled “Let the middleware collect dependencies”](#let-the-middleware-collect-dependencies) Register `cacheMiddleware()` at the React Router root, then use the request Cache from `cacheContext`. The middleware opens one render collector before loaders run and commits it after `next()` returns. This setup targets Cloudflare Workers. In another runtime, omit `waitUntil` or pass that runtime’s equivalent lifecycle hook. root.tsx (Cloudflare Workers) ```tsx import { waitUntil } from "cloudflare:workers" import { cacheMiddleware } from "@astilba/cache/react-router" import { authenticatedUserContext, authMiddleware, } from "./auth.server" import { cache } from "./cache.server" export const middleware = [ authMiddleware, cacheMiddleware({ cache, waitUntil, request: ({ context }) => { const user = context.get(authenticatedUserContext) return { userId: user.id, tenant: user.tenantId } }, }), ] ``` Each served Cache hit contributes the entry’s complete stored tag set and stored scope evidence. Each successful fill contributes its final tag set and the scope the kernel resolved for it. Reserved per-key and per-namespace tags remain internal and are filtered before header emission. Pass `currentRequest()` into each read so scope resolution sees the authenticated identity frame. Obtain the request Cache through `cacheContext` rather than importing the construction-time instance into route code: routes/product.ts ```ts import { t } from "@astilba/cache" import { cacheContext, currentRequest, } from "@astilba/cache/react-router" import type { Route } from "./+types/product" export async function loader({ context, params }: Route.LoaderArgs) { const cache = context.get(cacheContext) const productId = params.productId const product = await cache.getOrSet({ key: `product:${productId}`, request: currentRequest(), scope: "public", tags: [t`product:${productId}`], factory: ({ signal }) => loadProduct(productId, signal), }) return { product } } ``` An explicit public scope is a claim that identity cannot change the value. The development request guard helps detect reads through `ctx.request`, but it cannot inspect values captured in closures. See [Control cache sharing](/docs/cache/scopes-and-privacy/). ## Read the three response outcomes [Section titled “Read the three response outcomes”](#read-the-three-response-outcomes) | What the render consumed | Response behavior | | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | No managed dependency | Preserve the application’s `Cache-Control` and `Cache-Tag`. If no cache policy exists, add `Cache-Control: private`. | | Only eligible public dependencies | Preserve the application’s cache policy, replace `Cache-Tag` with the collected user tags, and default to `private` only when no policy exists. | | Any ineligible dependency | Force `Cache-Control: private`, remove `Cache-Tag`, and emit one `l3_ineligible` telemetry event. | A response becomes ineligible for one of four reasons: * `scope` — a managed dependency was tenant- or principal-scoped; * `scope-unreadable` — a served entry did not expose readable scope metadata; * `late-tag` — a required tag arrived after header commit; * `budget` — the tag list exceeded its configured count or byte budget. One unsafe dependency poisons a mixed render. Marking a tag `l3: false` never hides the associated entry’s scope: it can suppress a purge tag, but it cannot make private response content public. ## Build a custom response adapter deliberately [Section titled “Build a custom response adapter deliberately”](#build-a-custom-response-adapter-deliberately) The standalone collector is available to authors of another response adapter. It can declare a dependency that is not backed by a Cache entry and lets the adapter apply the returned decision itself: ```ts const collector = cache.collect() collector.dependsOn(t`site:theme`) const emission = collector.commitHeaders({ maxBytes: 16 * 1024, maxTags: 1000, }) ``` A bare `RenderCollector.dependsOn()` has no stored entry and therefore makes no scope claim. That absence is caller-trusted input, not evidence that the dependency is public. A custom adapter must independently establish that the rendered data is safe to share; when its scope is private or unknown, fail closed to a private response and omit its tags. Never use a bare declaration to hide a managed private dependency. Use `{ l3: false }` to keep a verified render-only tag out of the emitted header and out of timing and budget checks. The React Router middleware owns a different request-bound collector internally; it does not expose that collector for route code to mutate. Creating a standalone collector inside a React Router request does not add tags to the middleware’s eventual header. Use the automatic Cache hit/fill collection there, or write a custom adapter that declares and commits its own dependencies. This is different from `FactoryCtx.dependsOn()`. Factory-declared tags persist into the cached entry and automatically carry that fill’s scope into the request collector. `FactoryCtx.dependsOn(tag, { l3: false })` currently throws `NotImplementedError` because the stored entry format cannot preserve a per-tag emission flag for later hits. ## Stay within the header budget [Section titled “Stay within the header budget”](#stay-within-the-header-budget) The React Router adapter defaults to `L3_BUDGET_DEFAULT`: * `maxBytes: 16 * 1024` for the comma-joined header value; * `maxTags: 1000` collected tag occurrences. Override the limits with `cacheMiddleware({ l3Budget })`. Budgeting counts every collected occurrence and the comma separators. After an eligible decision, the adapter deduplicates tags before writing the actual header, so it may emit fewer bytes than it measured but never more. Cloudflare documents the current `Cache-Tag` syntax and aggregate response-header limit in [Purge cache by cache-tags](https://developers.cloudflare.com/cache/how-to/purge-cache/purge-by-tags/). Astilba’s defaults match the 16 KB and approximately 1,000-tag guidance; they do not implement the purge request itself. ## Account for immutable responses [Section titled “Account for immutable responses”](#account-for-immutable-responses) Some platform responses have immutable header guards, including redirect and fetch-originated responses. If direct header mutation fails, the middleware rebuilds the response with the same body, status, status text, and existing headers, then applies the decided posture. Network-error responses that cannot be rebuilt are returned untouched; they expose no usable cache headers. ## Know the remaining boundary [Section titled “Know the remaining boundary”](#know-the-remaining-boundary) Safe dependency collection, scope demotion, budget enforcement, and `Cache-Tag` emission exist today. The configured `Cdn` capability, `delete({ cdn })`, and `PurgeResult.edgePurged()` do not yet drive or await a real CDN purge. Until that path exists, response tags are useful for inspection and future integration but are not end-to-end invalidation support. Continue with [React Router](/docs/cache/react-and-server-apps/) for complete middleware setup, [Inspect cache behavior](/docs/cache/observability/) for ineligibility telemetry, or [Implementation status](/docs/cache/api-status/) for the preview ledger. # Consistency and resilience > Choose what a read must observe and which transient failures may reuse a stale value. In Astilba Cache, consistency controls what a read must observe. Resilience controls which failures may reuse a previously good value. They are related, but they are not the same switch. The **Registry** is the authoritative invalidation record, the **Bus** delivers its updates to active instances, and **L2** is the shared Store used for values and recovery data. See [Cache fundamentals](/docs/cache/core-concepts/) for the complete vocabulary. These consistency levels become meaningful when `Registry`, `Bus`, and `L2` are configured together. L2 holds values and the durable recovery mirror; `createCache()` refuses a Registry-plus-Bus reader without it. Without the coordinated invalidation path, the current kernel treats decoded entries as fresh and `consistency` does not create a live check. ## Choose a consistency level [Section titled “Choose a consistency level”](#choose-a-consistency-level) | Level | Current behavior | Cost | | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | | Eventual — default | Uses verified local invalidation knowledge. Unknown or suspect knowledge follows the configured unknown policy. | Usually no Registry round trip on a warm, known hit. Conservative misses or checks occur while knowledge reconverges. | | Strong — opt in | Performs a live, un-memoized Registry check before serving a stored entry and before running the factory for a miss. A soft-stale value is refilled in the foreground instead of returned through the eventual stale path. | Adds authoritative coordination to stored-entry reads and fills, and surfaces Registry failures. | Choose strong mode with `consistency: “strong”` on the call or in `defaults.consistency`. A per-call option wins; when neither is present, the read is eventual. * Unknown or suspect knowledge never validates an entry as fresh or grace-servable by itself. * A hard invalidation observed during a fill can fence the result. When verified knowledge advanced, the kernel re-mints the birth epoch and may refetch within a three-attempt budget before surfacing `FencedError` or a miss entry. On a strong miss, the pre-factory live check establishes the current Registry epoch from the canonical key, namespace, and caller-declared tags. Tags discovered later through `FactoryCtx` join the final stored set and write-back fence. They were not available for individual pre-checking, but a delivered hard purge on one during the fill can still fence and retry the result. Strong mode may still use an eligible stale candidate when its foreground factory fails with a classified transient error and the call declared `grace`. It rechecks the candidate at serve time; a hard-dead or still-unknown value is not served. ## Decide what unknown means [Section titled “Decide what unknown means”](#decide-what-unknown-means) Set `defaults.unknownPolicy` for eventual reads: | Policy | Result | | -------------------------- | ----------------------------------------------------------------------------------------------------------- | | `registry-check` — default | Ask the Registry for live tag watermarks. If the check cannot establish safety, the read fails closed. | | `miss` | Treat the entry as unusable and continue to the fill path. | | `error` | Throw `UnknownTagError` with the user tags whose safety could not be established. The factory does not run. | `takedownSensitive: true`, at the top level or inside `defaults`, selects that same throwing posture. If either level is `true`, a `false` value at the other level does not cancel it. It is a safety override and therefore outranks even an explicit `unknownPolicy`; use it when refilling unknown content could resurrect material under a takedown or legal embargo. The source Workers factory explicitly chooses `registry-check`. Its request-driven carrier helps future eventual reads reconverge, but it never turns unverified knowledge into a current-read hit; a read that is still suspect follows the fail-closed policy immediately. ## Choose the strong-outage posture [Section titled “Choose the strong-outage posture”](#choose-the-strong-outage-posture) By default, a failed Registry check for a strong read throws `RegistryUnavailableError`. Configure `defaults.onUnavailable: “eventual”` to degrade only that call to eventual rules instead: ```ts const cache = createCache({ namespace: "storefront", clock, rng, l2, registry, bus, defaults: { consistency: "strong", onUnavailable: "eventual", }, telemetry: sink, }) ``` The degradation emits `strong_degraded` with reason `registry_unreachable`. Eventual does not mean “serve whatever is stored”: the call still uses verified local knowledge and the configured unknown policy. Use this opt-out only when that conservative eventual posture is acceptable during a Registry outage. Store availability is separate from Registry availability. A classified `throttled` or `unavailable` Store read emits `store_read_suppressed` and behaves as a tier miss; an L1 hit may therefore absorb an L2 outage, while a call without another usable tier proceeds to its factory. Unclassified Store errors still propagate unchanged. ## Serve stale data only for classified failures [Section titled “Serve stale data only for classified failures”](#serve-stale-data-only-for-classified-failures) A failed factory does not modify a stored good value. A transient failure may reuse a stale candidate only after the candidate is revalidated at serve time. loader.ts ```ts import { httpError } from "@astilba/cache" export async function loadProduct(url: URL) { const response = await fetch(url) if (!response.ok) { throw httpError(response) } return response.json() } ``` The default `isRetriableHttp()` classifier accepts: * any `TypeError`, intended to cover fetch and network rejections; * cache-originated `CacheTimeoutError` values; * HTTP 408, 425, 429, 500, 502, 503, and 504; * Cloudflare 520–527 and 530 responses. Caller-originated timeouts and fact-like HTTP responses such as 403, 404, and 410 are not retriable by default. Replace the classifier with `defaults.staleIfError` when your application has a different failure vocabulary. Cache invokes that classifier only when the call declares `grace`. Compatible singleflight callers share origin work, but a transient shared failure does not erase the stale candidate each caller read before joining. If the factory-running call had no candidate, a waiting caller with its own eligible candidate can still revalidate and serve it; a caller with no candidate propagates the failure. A hard purge observed before serve-time revalidation makes the candidate ineligible. ## Keep facts visible [Section titled “Keep facts visible”](#keep-facts-visible) Negative entries are never served through grace or stale-on-error. Declaring `notFoundTtl` allows an `HttpError` with status 404 to take the negative L2 path. A negative fill and a later read that reaches the invalidation-fresh negative resolve the fact as `undefined`, never as the internal stored placeholder. The singleflight outcome depends first on what the factory-running caller observed and whether the compatible calls declared `grace`: * A leader with a grace-eligible, still-servable stale value suppresses the negative write. Its value and evidence become the shared outcome for every compatible joiner. * If that candidate revalidates as dead or unknown, the leader attempts the negative as the shared result. No caller may fall back to a candidate that cannot be established servable. * If the leader has no grace-eligible candidate, it attempts the negative once inside the shared fill window and shares the not-found fact. Each waiter then uses its own earlier read: one with `grace` and a still-servable stale value returns it, while one without a servable stale candidate returns `undefined`. Without `grace`, every compatible caller takes the negative disposition even if it observed a stale value. Serve-time validation may instead throw `RegistryUnavailableError` under the configured unavailable posture. Waiters do not write the fact again. Before writing a negative, the L2 negative-write guard reads the current entry and refuses the write when it finds a decodable value that is fresh or stale under the serving rules. Cache also refuses when it cannot establish that value’s invalidation verdict. The current not-found disposition still resolves `undefined` with `durable: false`, but the Store remains unchanged. The guard is local to the L2 being written and does not inspect L1. Codec-incompatible, undecodable, or dead bytes do not trigger this guard; normal newer-envelope arbitration still applies afterward. A negative result that reaches the serve path skips L1 hydration and best-effort deletes an older L1 value unless the guard refused the write. This cleanup still runs after a classified retryable L2 write failure. If deletion fails, the older L1 copy can still win a later tier read until it is evicted or overwritten. A guard refusal leaves L1 unchanged. Both a stale candidate suppressing the 404 and the guard refusing the write emit `neg_suppressed`. Durations are not enforced The presence of `grace` currently opts a stale candidate into error fallback, but elapsed grace is not measured. Likewise, `notFoundTtl` opts into a negative entry without expiring it after the declared duration. Entry `age` is measured from the served envelope’s `bornMs` fill-start timestamp, but TTL and grace still do not consume that elapsed time. An eventual soft-stale read also awaits its refresh in the current implementation, then returns the stale value. Background adoption, queue retry, and refresh completion tracking are not yet present. Use `cache.explain(key)` to inspect the current local verdict and reader state without changing it. The method performs no live Registry check or resynchronization, so it is useful for diagnosis but never a substitute for a strong read. See [Inspect cache behavior](/docs/cache/observability/). ## Related [Section titled “Related”](#related) * [How Cache works](/docs/cache/how-it-works/) explains the invalidation knowledge behind these read decisions. * [Cache fundamentals](/docs/cache/core-concepts/) defines Registry, Bus, consistency, and grace in plain language. * [Read and cache values](/docs/cache/reading-and-filling/) follows the foreground fill and stale return shapes. * [Inspect cache behavior](/docs/cache/observability/) explains the point-in-time verdict and reader witness. * [Implementation status](/docs/cache/api-status/) records unfinished timing and release behavior. # Inspect cache behavior > Witness stored entries with explain() and route operational events through telemetry sinks. Astilba Cache exposes two complementary observability surfaces. `cache.explain()` takes a point-in-time witness of one public key, while telemetry reports operational events as they occur. Neither surface turns uncertainty into a stronger correctness claim. ## Inspect one key [Section titled “Inspect one key”](#inspect-one-key) Call `explain(key)` with the same application-facing key used by `getOrSet()`: ```ts const explanation = await cache.explain(`product:${productId}`) switch (explanation.kind) { case "present": console.log({ tier: explanation.tier, identity: explanation.identity, verdict: explanation.verdict, reader: explanation.reader, }) break case "absent": console.log("not present", explanation.reader) break case "read-failed": console.warn("presence unknown: a Store did not answer", explanation.reader) } ``` The `kind` discriminant separates three materially different observations: * `present` means a decodable entry was found in L1 or L2; * `absent` means every probed tier answered and no decodable entry was found; * `read-failed` means at least one classified Store read failure was suppressed and no other tier supplied the entry, so presence is unknown. A missing, undecodable, or codec-incompatible entry is a reportable `{ kind: “absent”, tier: “miss” }` result only when every probed Store answered. A classified Store failure is not laundered into the same answer: if one tier fails while another contains only an undecodable or incompatible entry, the result remains `read-failed`. That arm deliberately has no `tier` or `identity`. | Result field | What it witnesses | | -------------------- | ------------------------------------------------------------------------------------------------------------------- | | `kind` | Whether the probe found an entry, proved absence, or could not establish either answer because a Store read failed. | | `key` | The application-facing key passed to `explain()`. | | `tier` | On `present`, the first decodable copy observed in L1 or L2. On `absent`, `miss`. Absent from `read-failed`. | | `identity.tags` | The stored user and reserved tags. | | `identity.scope` | The stored scope literal, or an explicit `unreadable` marker. | | `identity.bornEpoch` | The invalidation epoch at which the entry was born. | | `identity.ttl` | Stored TTL evidence. Current entries report `{ kind: “not-stored” }`. | | `identity.kind` | A value or negative entry. | | `verdict` | Current `fresh`, `stale`, `dead`, or `unknown` classification and the soft and hard epochs behind it. | | `reader` | The local reader’s applied epoch, suspicion state, terminal recovery state, and live-channel state. | Without a coordinated invalidation reader, a present entry receives the codec-only fresh verdict and zero epochs. That says no local invalidation authority exists to classify it otherwise; it is not evidence from a Registry check. `reader.channel` distinguishes `never-established`, `established`, and `lost`. This matters because a Bus that never completed its first handshake can still have `suspect: false`: no continuity gap has occurred, but there is also no established live channel. Read both fields when diagnosing delivery health. ## Understand the witness boundary [Section titled “Understand the witness boundary”](#understand-the-witness-boundary) `explain()` reports what this Cache instance already knows. It deliberately does not: * perform a live Registry check; * trigger mirror recovery or resynchronization; * hydrate an L2 result into L1; * preserve a historical dependency graph; * accept a scope argument. The method canonicalizes the key at the default public scope. It cannot directly address tenant or principal-derived variants. Within a React Router request, `requestDependencies` can still show the dependencies the current render has recorded, including their scope evidence and any `l3` flag. The L1 and L2 probes call each Store’s ordinary `get()`. Cache performs no write while explaining, but a Store may have read behavior of its own; for example, `memory()` updates LRU recency on a successful read. Use explain for diagnosis, not synchronization If the witness reports `suspect` or `unknown`, make the production read through the normal Cache API. That path applies the configured unknown policy, live checks, and bounded recovery rules. Calling `explain()` does not repair the state it describes. ## Configure telemetry [Section titled “Configure telemetry”](#configure-telemetry) Pass a sink directly for local use, or use `TelemetryConfig` when you need hosted pseudonymization or sink-failure reporting: ```ts const cache = createCache({ namespace: "storefront", clock, rng, l2, telemetry: { sink: (event) => logger.info(event), onSinkError: (error) => logger.warn({ error }, "cache telemetry failed"), }, }) ``` Built-in event delivery swallows both synchronous sink throws and asynchronous sink rejections so observability code cannot fail a read, fill, or response. `onSinkError` is called for a swallowed failure; failures from that hook are swallowed too. For a hosted sink, set `hosted: true` and provide a project `salt`. String fields other than the event type are HMAC-pseudonymized before delivery. Hosted mode without a salt suppresses the event instead of forwarding raw strings. ## Use the event catalog [Section titled “Use the event catalog”](#use-the-event-catalog) `TELEMETRY_EVENTS` is the public catalog, and `TelemetryEventName` is the union of its values. | Event | Current source behavior | | ------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `writeback_throttled` | A retryable throttled L2 write was suppressed and the origin result remained non-durable. | | `l2_write_error` | Another retryable L2 write failure was suppressed. | | `singleflight_option_mismatch` | Production mode ran incompatible same-key work separately. | | `store_read_suppressed` | A classified `throttled` or `unavailable` Store read failed. The serving path treated that tier as a miss; the event includes the structural code and canonical key. | | `neg_suppressed` | An opted-in 404 was suppressed in favor of a grace-eligible stale value, or the L2 negative-write guard refused it because L2 held a decodable value whose invalidation verdict was fresh or stale, or could not be established. | | `private_evicted` | A configured `memory()` sink observed a principal-scoped entry evicted by an LRU bound. | | `poll_tick_failed` | Request-driven recovery work rejected in the Workers carrier or React Router lifecycle integration. | | `l3_ineligible` | React Router demoted a managed response for `budget`, `late_tag`, `scope`, or `scope_unreadable`. | | `regid_divergence` | The Coordinator writes a Workers log when a derived Registry identity differs from its journaled identity. | | `bus_dial_failed` | The invalidation reader received `lost` without a currently established channel: an initial dial or a later redial did not complete its `hello` handshake. | | `strong_degraded` | A failed strong Registry check degraded that call to eventual because `onUnavailable: “eventual”` was configured. The current reason is `registry_unreachable`. | | `registry_degraded` | Reserved in the catalog; no current emit site. | | `state_stale` | Reserved in the catalog; no current emit site. | The catalog closes the event-name vocabulary, not every event payload. Treat fields other than `type` according to the specific event you consume. ## Wire adapter and memory events separately [Section titled “Wire adapter and memory events separately”](#wire-adapter-and-memory-events-separately) The React Router middleware accepts its own `telemetry` and `onSinkError` options for `poll_tick_failed` and `l3_ineligible`. Pass the same sink explicitly if you want those events beside kernel events: ```ts cacheMiddleware({ cache, telemetry: sink, onSinkError, waitUntil }) ``` The `memory()` driver also accepts `telemetry` and `onSinkError`. It emits `private_evicted` only when memory pressure removes a `usr:`-scoped entry, with count and byte information but no key, hash, or tag. TTL expiry, explicit deletion, and replacement do not emit that event. `createWorkersCache()` accepts `telemetry` for kernel and Workers-carrier events. It still fixes its internal L1 construction without a separate memory sink, so receiving `private_evicted` from a memory L1 requires direct composition with `createCache()` and `memory({ telemetry })`. React Router middleware telemetry is likewise an explicit option; pass the same sink to both surfaces when you want one event stream. Continue with [Cache HTTP responses](/docs/cache/response-caching/) for the response safety gate, [Control cache sharing](/docs/cache/scopes-and-privacy/) for private storage rules, or [API reference](/docs/cache/api-reference/) for the complete types. # How Cache works > Follow one value through storage, invalidation, recovery, and a safe refill. Astilba Cache separates the path that stores values from the path that decides whether those values are still legal to serve. If L1, L2, Registry, or Bus are unfamiliar, read [Cache fundamentals](/docs/cache/core-concepts/) first. | Plane | Components | Responsibility | | ----------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------- | | Value path | Cache kernel, local L1 Store, shared L2 Store, factory, Codec | Find, validate, fill, and store a value. | | Invalidation path | authoritative Registry, delivery Bus, L2 mirror, replication reader | Mint invalidation order and distribute tag watermarks to every active instance. | The separation is deliberate. A fast storage hit is useful only if the reader can also establish that the entry has not been invalidated. ## Follow one read [Section titled “Follow one read”](#follow-one-read) 1. **Resolve a canonical key.** Cache combines the namespace version, resolved scope, and user key. Public, tenant, and principal-derived values therefore occupy different storage keys. 2. **Read L1, then L2.** L1 is an optional isolate-local `Store`. L2 is the shared or durable `Store` and is currently required whenever a factory must run. A classified throttled or unavailable Store read is observed through telemetry and treated as a miss for serving; an unclassified error propagates. 3. **Check the codec before decoding.** Stored metadata carries a codec identity. A foreign or unsupported identity becomes a miss before its bytes reach the decoder. 4. **Validate invalidation knowledge.** With coordinated invalidation configured, the entry’s birth epoch is compared with the soft and hard watermarks for all of its tags. 5. **Serve or fill.** A fresh value entry is returned. A fresh negative entry returns `undefined`. A soft-stale eventual value follows the refresh path; negatives never do. A dead or unknown entry is not served directly, so the factory runs or an error is surfaced. A strong coordinated miss establishes the Registry’s current epoch before the factory starts. 6. **Close and fence the result.** The final stored tag set combines the call and factory declarations. Before write-back, Cache checks those tags for a hard purge delivered during the fill. If verified knowledge advanced, it can re-mint the birth epoch and refetch within a bounded three-attempt budget instead of publishing a born-dead value. If all attempts are fenced, the plain `getOrSet()` form fails closed with `FencedError`; `getOrSetEntry()` returns a non-durable miss. 7. **Write by scope.** Shared public and tenant entries may reach L2. Principal-derived entries are L1-only. A successful fill also hydrates L1 when one is configured. Compatible foreground fills share one in-isolate promise. A successful shared fill or revalidated stale serve carries the served value and its evidence to callers waiting for that work. If the factory-running call had no stale candidate and shares only a classified transient failure, each waiting caller makes its own stale-on-error decision using the candidate from its earlier read and revalidates that candidate before serving it. When serve-time validation completes, an opted-in 404 has three singleflight dispositions: * A factory-running caller with `grace` and a still-servable stale value suppresses the negative write. Every compatible joiner inherits that value and its evidence. * If that candidate revalidates as dead or unknown, the leader attempts the negative as the shared result. * If the leader has no grace-eligible candidate, it attempts one negative L2 write and shares the not-found fact. A waiter with its own grace-eligible, still-servable stale value can return it; one without a servable stale candidate receives `undefined`. Without `grace`, all compatible callers take the negative disposition even if they observed stale values. Validation may instead throw under the configured Registry-unavailable posture. Before persistence, the L2 negative-write guard reads the existing entry in the same Store with the serving path’s codec and invalidation rules. A readable value that is fresh or stale—or whose invalidation verdict cannot be established—refuses the negative. Callers taking the not-found disposition still receive `undefined`, but the attempted negative is non-durable and the Store remains unchanged. A negative result that reaches the serve path skips L1 hydration and triggers best-effort removal of any older L1 copy unless the guard refused the write. This cleanup still runs after a classified retryable write failure. A guard refusal leaves L1 alone, while a waiter serving its own candidate retains that candidate’s evidence. Cross-isolate exclusion is separate and opt-in through a `Lock` driver. ## How invalidation travels [Section titled “How invalidation travels”](#how-invalidation-travels) The Registry is the authority. It keeps a monotone epoch and two watermarks per tag: * a **soft watermark** makes older values stale; * a **hard watermark** makes older values unreadable. The Bus is the warm path. It carries ordered frames to active isolates. A reset, declared gap, or non-contiguous frame marks local knowledge suspect. The reader separately records whether the channel has never established, is established, or was lost, so an operator can distinguish transport reachability from continuity suspicion. The L2 replication mirror stores one mutable pointer plus immutable delta batches and snapshots. A suspect reader first tries a bounded contiguous delta replay. If a persistent hole spends that retry budget, it can load the exact snapshot named by the pointer and then replay the tail above it. Missing, corrupt, foreign, or otherwise unfillable recovery data keeps the reader fail closed after both bounded recovery paths are exhausted. Recovery has two triggers: * a suspect read performs one bounded fast resync attempt before classifying the entry; * an attached replication poller observes pointer liveness and drives bounded retry and snapshot escalation outside the read path. `createWorkersCache()` supplies an unawaited carrier on `getOrSet()` and `getOrSetEntry()` that drives both the poller and due Bus redials at most once per second. The React Router adapter also ticks at request entry and can adopt that work with `waitUntil`. An idle isolate receives no ticks, but it also serves no reads; the next Cache value read observes the elapsed state and starts one. A raw runtime embedding must provide its own driver if it wants proactive recovery rather than relying only on reactive read-path recovery. ## How a rendered response earns tags [Section titled “How a rendered response earns tags”](#how-a-rendered-response-earns-tags) The React Router middleware binds one render collector to each request. When Cache serves a hit or completes a fill inside that frame, it records the entry’s tags and scope evidence. After route work completes, the middleware commits the collector: 1. Any tenant, principal, or unreadable scope makes the response private and removes response tags. 2. A late or over-budget tag also makes the response private. 3. An eligible public render receives a deduplicated `Cache-Tag` header containing user tags; reserved key and namespace tags stay internal. 4. The middleware preserves an application-authored shared-cache policy only for an eligible public render and never creates one. The privacy and timing/budget gates above override any shared policy and force `Cache-Control: private`. Without an application policy, the middleware also defaults to private. This connects value dependencies to a shared-response purge vocabulary, but the current `Cdn` path does not send the purge. See [Cache HTTP responses](/docs/cache/response-caching/). ## What “unknown” means [Section titled “What “unknown” means”](#what-unknown-means) Tag knowledge is effectively tri-state: * **known** — the reader has verified watermarks for the tag; * **unknown** — the tag has not been verified; * **suspect** — a transport or recovery event means the warm map cannot be trusted. Unknown and suspect are not treated as fresh. The default eventual policy attempts a live Registry check; an application may choose a conservative miss or a throwing `UnknownTagError` posture instead. `takedownSensitive` forces that throwing posture and outranks the general policy. When coordinated invalidation is active, a strong read uses a live, un-memoized Registry check before serving a stored entry and before filling a strong miss. Registry failure throws `RegistryUnavailableError` unless `onUnavailable: “eventual”` degrades that call to the conservative eventual rules and emits `strong_degraded`. ## Timing is the major unfinished layer [Section titled “Timing is the major unfinished layer”](#timing-is-the-major-unfinished-layer) The invalidation ordering above is active. Elapsed-time expiry is not: TTL, grace, and `notFoundTtl` are present in the types but their durations are not enforced. Entry `age` is now measured from the served envelope’s `bornMs` fill-start timestamp, but it is evidence only and does not drive freshness. A stale refresh is also awaited in the current kernel instead of being adopted by a background lifecycle. Keep those limitations separate from the implemented epoch and watermark model. ## Related [Section titled “Related”](#related) * [Runtime architecture](/docs/cache/architecture/) maps these operations to the supplied capability contracts. * [Cache fundamentals](/docs/cache/core-concepts/) provides the plain-language vocabulary. * [Invalidate cached data](/docs/cache/tags-and-invalidation/) explains soft and hard mutations from the caller’s side. * [Consistency and resilience](/docs/cache/consistency-and-resilience/) covers live checks and stale-on-error behavior. * [Inspect cache behavior](/docs/cache/observability/) shows the same entry and reader state as a point-in-time witness. # Runtime architecture > See how the portable Astilba Cache kernel composes storage, coordination, time, and encoding capabilities. Astilba Cache keeps cache semantics in a portable kernel and receives runtime capabilities through typed contracts. The kernel owns keys, scopes, tier order, decode safety, fill coordination, and invalidation decisions. A runtime supplies storage, coordination, time, randomness, recovery ticks, and any custom encoding. This page explains the runtime capability model. For application-level definitions and a smaller starting point, begin with [Cache fundamentals](/docs/cache/core-concepts/) or the [source walkthrough](/docs/cache/quickstart/). ## Capabilities at the boundary [Section titled “Capabilities at the boundary”](#capabilities-at-the-boundary) | Capability | What Cache uses it for | Current requirement | | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Clock` and `Rng` | Injected time and randomness keep the kernel portable and deterministic in tests. | Required by `createCache()`; a future runtime preset should normally supply them. | | `Store` | Key/value I/O for local L1, shared L2, and the replication mirror. Classified throttled or unavailable reads become observable misses; unclassified failures propagate. | L2 is required whenever a factory must run. `memory()` and `cloudflareKV()` are implemented source drivers. | | `Registry` | The authoritative record of soft, hard, and namespace invalidation. | Required by `expire()`, `delete()`, and `clear()`. | | `Bus` | The live delivery path for ordered invalidation events, including explicit channel loss. | Participates in coordinated read validation when Registry and L2 are also configured. The Cloudflare subpath includes a Durable Object Bus and tick-driven redial wrapper. | | `Codec` | Value encoding and a wire identity checked before decode. | Optional when the built-in JSON round trip is sufficient. | | `Lock` | Cross-isolate exclusion and write arbitration. | Optional and only used when a driver is supplied and the call opts in. | | Render collector | Records value dependencies and decides whether a response may carry their user tags. | The React Router middleware binds and commits it automatically. | | `Cdn` | A future edge-purge boundary. | Declared but not wired in the current kernel; response-tag emission does not invoke it. | | Poll tick driver | Calls the kernel’s attached recovery poller outside the foreground read work. | `createWorkersCache()` carries read-triggered ticks; React Router can start and lifecycle-adopt request-entry ticks. Raw embeddings need an equivalent driver if they want proactive recovery. | These contracts keep the correctness rules independent of a storage vendor. They do not make every driver combination equivalent: coordinated invalidation needs a complete coordination path. ## Follow one read or fill [Section titled “Follow one read or fill”](#follow-one-read-or-fill) 1. Cache resolves the namespace, scope, and user key into one canonical storage key. 2. It checks L1 before L2, verifies the stored codec identity, and reconstructs the entry. 3. When coordinated invalidation is configured, it decides whether its tag knowledge is sufficient for the requested consistency level. A strong miss performs a live check before its factory. 4. On a miss, compatible callers share one in-isolate factory execution. 5. When the factory settles, Cache validates the union of call-level and factory-declared tags. 6. Before write-back, Cache checks the final tag set for a conflicting hard invalidation, then writes only to tiers allowed by the resolved scope. See [Read and cache values](/docs/cache/reading-and-filling/) for return metadata, singleflight compatibility, codec changes, and fill failures. See [How Cache works](/docs/cache/how-it-works/) for the invalidation and recovery path around the same operation. ## Compose the current source preview [Section titled “Compose the current source preview”](#compose-the-current-source-preview) | Configuration | Current behavior | | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `clock` + `rng` + `l2` | Reads and fills through the portable kernel without coordinated invalidation. | | Add `l1` | Adds an isolate-local read tier and retains principal-derived, L1-only values. | | Add `registry` | Enables the purge methods. Reads do not build the coordinated invalidation path without Bus. | | Add `registry` + `bus` alongside `l2` | Enables coordinated validation, live delivery, explicit channel-state tracking, delta-and-snapshot recovery, and construction of the internal replication poller. A Registry-plus-Bus configuration without L2 is rejected. | | Add `lock` | Allows opted-in calls to coordinate work across isolates. No production Lock driver is exported. | | Add a custom `codec` | Changes the wire format and identity. Accepted older identities must still be decodable by that codec. | | Run through `cacheMiddleware()` | Binds a request-scoped render collector, records served entries with scope evidence, emits eligible response tags, and demotes unsafe responses. | | Use `createWorkersCache()` | Supplies Clock, Rng, bounded memory L1, KV L2, lazily minted Coordinator Registry handles, a tick-redialed Durable Object Bus, and a read-triggered recovery carrier with safe source defaults. | Supplying Bus without Registry does not build the invalidation reader. Registry without Bus can still accept purge commands, but it is not a coordinated read configuration. Supplying Registry and Bus without L2 throws at construction because the reader would have no recovery mirror. Both `createCache()` and `createWorkersCache()` construct without I/O. Coordinated lifecycle work is deferred until a read or purge needs it. The Workers factory can therefore live at module scope while minting Durable Object stubs inside the request that uses them. ## Keep runtime integrations outside the kernel [Section titled “Keep runtime integrations outside the kernel”](#keep-runtime-integrations-outside-the-kernel) The reviewed source snapshot exposes two publish-shaped adapter entry points: * `@astilba/cache/cloudflare` contains the Workers factory plus its KV, Coordinator, Registry, Bus, tick-driven redial, and request carrier pieces. * `@astilba/cache/react-router` contains React Router v8 server middleware, typed Cache context, request-frame access, request-entry recovery lifecycle adoption, and scope-aware response-tag collection. Both are part of the package export map and covered by source tests. Neither is installable from npm yet, and the integration worker and React Router fixture remain test hosts rather than application templates. Source preview only These combinations describe the reviewed source snapshot. `@astilba/cache` is not published and does not yet have a public checkout, supported installation, compatibility policy, or production deployment path. ## Related [Section titled “Related”](#related) * [Driver implementations](/docs/cache/drivers-and-status/) lists each contract and integration boundary. * [Consistency and resilience](/docs/cache/consistency-and-resilience/) explains live checks, unknown knowledge, and stale-on-error policy. * [Cache HTTP responses](/docs/cache/response-caching/) explains the render collector and response-header gate. * [Implementation status](/docs/cache/api-status/) records incomplete and provisional surfaces. # API reference > Reference the root, Cloudflare, and React Router exports in the current @astilba/cache source snapshot. This page documents the complete root export surface and the two public adapter subpaths in the current source snapshot. Start with the [overview](/docs/cache/overview/) or [source walkthrough](/docs/cache/quickstart/) if you are learning the library; use this page when you need an exact method, option, result field, or driver contract. Unreleased and unevenly implemented `@astilba/cache` is not published to npm. Some declarations describe intended behavior that still throws or remains inert. Each section calls out important boundaries; [Implementation status](/docs/cache/api-status/) is the authoritative implementation ledger. ## Create a cache [Section titled “Create a cache”](#create-a-cache) ### `createCache(config)` [Section titled “createCache(config)”](#createcacheconfig) Creates a `Cache` instance from application-supplied capabilities. ```ts const cache = createCache({ namespace: "storefront", clock, rng, l2: store, }) ``` The raw constructor requires `namespace`, `clock`, and `rng`. Construction performs no I/O; retention registration and Bus establishment are deferred until the first read or purge that needs them. A factory fill also requires `l2`; without it, the fill throws `NotImplementedError`. `createWorkersCache()` is the higher-level Workers constructor. ### `CacheConfig` [Section titled “CacheConfig”](#cacheconfig) | Field | Type | Meaning and current boundary | | ------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `namespace` | `string` | Required stable boundary for canonical keys and namespace invalidation. | | `clock` | `Clock` | Required source of logical time. | | `rng` | `Rng` | Required source of randomness. | | `l1` | `Store` | Optional local tier. Retains principal-derived values that cannot be written to shared storage. | | `l2` | `Store` | Shared or durable tier. Currently required whenever a factory runs and can also hold replication-mirror objects for recovery. | | `registry` | `Registry` | Authoritative invalidation driver. Required by the purge methods. | | `bus` | `Bus` | Live invalidation delivery. Coordinated validation is built when Registry and L2 are also configured. Registry plus Bus without L2 throws at construction. | | `cdn` | `Cdn` | Declared L3 purge capability; not invoked by the current kernel. | | `lock` | `Lock` | Optional cross-instance fill lock. A read opts in with `lock: true`. | | `codec` | `Codec` | Value encoder and wire identity. Defaults to the built-in JSON round trip. | | `defaults` | `CacheDefaults` | Instance policy defaults. Timing fields remain partial; consistency and unavailable-policy fields are active. | | `telemetry` | `TelemetrySink \| TelemetryConfig` | Receives operational events. Hosted mode pseudonymizes string fields when a salt is present; configured delivery isolates sink failures and can call `onSinkError`. | | `takedownSensitive` | `boolean` | Makes unknown invalidation knowledge throw `UnknownTagError`. A `true` value here or in `defaults` activates the safety override; `false` at one level does not cancel `true` at the other. | | `dev` | `boolean` | Makes incompatible same-key singleflight calls fail loudly and guards request reads inside explicitly public factories. | ### `CacheDefaults` [Section titled “CacheDefaults”](#cachedefaults) | Field | Meaning and current boundary | | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `ttl` | Default freshness duration. Declared but not applied; elapsed expiry is unfinished. | | `grace` | Default stale window. Declared but not applied; elapsed grace is unfinished. | | `maxEntryRetention` | Maximum retention registered lazily when a configured Registry is first used. It has no registration target without that driver. | | `consistency` | Default `eventual` or `strong` consistency. A per-call value wins; an omitted value otherwise resolves to eventual. | | `unknownPolicy` | Chooses `registry-check`, `miss`, or `error` for unknown invalidation knowledge. The error posture throws `UnknownTagError`. | | `staleIfError` | Replaces the default `isRetriableHttp()` failure classifier. | | `graceBackoff` | Declared retry backoff for grace behavior; not consumed today. | | `maxSyncLag` | Base cadence for the attached replication poller. Defaults to the Workers profile’s 60 seconds when omitted. | | `acceptCodecs` | Additional stored codec identities the current Codec is allowed to decode. | | `heartbeatInterval` | Reader-side heartbeat interval used to derive the invalidation-silence threshold. The Workers factory defaults it to 30 seconds; the Coordinator deployment variable is configured separately. | | `onUnavailable` | With `“eventual”`, a failed strong Registry check degrades that call to eventual and emits `strong_degraded`. Otherwise the read throws `RegistryUnavailableError`. | | `takedownSensitive` | The defaults-level form of the unknown-as-error safety override. If either this value or the top-level value is `true`, it outranks `unknownPolicy` and unknown knowledge throws `UnknownTagError`. | ## Read or fill values [Section titled “Read or fill values”](#read-or-fill-values) ### `Cache` methods [Section titled “Cache methods”](#cache-methods) | Method | Returns | Current behavior | | ------------------------ | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `getOrSet(options)` | `Promise` | Returns a usable hit or runs the factory. When an opted-in 404 takes the negative disposition, it resolves as `undefined`; include `undefined` in `T`. A terminal fenced fill throws `FencedError`. | | `getOrSetEntry(options)` | `Promise>` | Adds `skip()` and returns read metadata. A terminal fenced fill becomes a miss entry. | | `expire(selector)` | `Promise` | Applies a soft invalidation through Registry. | | `delete(selector)` | `Promise` | Applies a hard invalidation through Registry. CDN modes are not wired. | | `clear()` | `Promise` | Bumps the local namespace version and hard-invalidates the reserved namespace tag. | | `expireAll(guard)` | `Promise` | Declared with an explicit origin-load acknowledgement; currently throws `NotImplementedError`. | | `deleteAll(guard)` | `Promise` | Declared with an explicit origin-load acknowledgement; currently throws `NotImplementedError`. | | `collect()` | `RenderCollector` | Creates a scope-aware L3 dependency collector. React Router binds one per request so hits and fills contribute automatically. | | `explain(key)` | `Promise` | Witnesses the default-public key in L1 then L2, its stored identity, current local verdict and reader state, and any request-scoped attribution. It distinguishes a present entry, proven absence, and a suppressed Store read failure. | ### Read option types [Section titled “Read option types”](#read-option-types) `GetOrSetOptions` and `GetOrSetEntryOptions` extend `GetOptions` with their respective factory type. | `GetOptions` field | Meaning and current boundary | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `key` | Required application-facing key. Namespace, scope, and namespace version are added internally. | | `tags` | Optional dependency tags. Use branded values created by the tag helpers. | | `ttl` | Intended freshness duration. It participates in singleflight compatibility but elapsed time is not enforced. | | `grace` | Opts a stale candidate into classified error fallback. The declared duration is not enforced. | | `notFoundTtl` | Its presence allows an `HttpError` 404 to attempt a negative L2 entry. A negative fill and a later read that reaches an invalidation-fresh negative surface `undefined`; the duration is not enforced. A current servable value in that L2 can refuse the negative write. | | `scope` | `“public”` or `{ tenant }`. When omitted, visible identity derives a principal-local scope; contextless work resolves public. | | `consistency` | `“eventual”` or `“strong”`. Strong live-checks a stored entry and pre-checks a miss before running its factory when coordinated invalidation is active. | | `lock` | Requests a configured cross-instance Lock. Without a driver, `true` currently continues unlocked. | | `request` | Adapter-provided `RequestContext` used for identity derivation and the development public-scope guard. | | `factory` | Async origin loader. Its context is `FactoryCtx` or `EntryFactoryCtx`. | The exported option types are `GetOptions`, `GetOrSetOptions`, and `GetOrSetEntryOptions`. Compatible calls share one foreground factory execution only when key, tags, TTL, grace, negative-cache TTL, resolved scope, codec identity, consistency, and API form agree. A successful fill or an already-revalidated stale serve is shared with its evidence. If the factory-running call had no stale candidate and shares only a classified transient failure, each waiting caller makes its own stale-on-error decision using the candidate it read before joining. The classifier is not invoked when `grace` is absent. If no caller has a candidate, both API forms propagate the error. If serve-time revalidation rejects a waiting caller’s candidate after a hard invalidation, the plain form propagates the classified error while the entry form returns its documented miss result. When serve-time validation completes, an opted-in shared 404 has three served dispositions: * A factory-running caller with `grace` and a still-servable stale value suppresses the negative write. Every compatible joiner inherits that value and its evidence. * If that grace-eligible candidate revalidates as dead or unknown, the leader attempts one negative L2 write and every compatible joiner inherits the negative result. * If the leader has no grace-eligible candidate, it attempts the negative once inside the singleflight window and shares the not-found fact. Each waiter then uses its own earlier read: one with a grace-eligible, still-servable stale value returns it, while one without a servable stale candidate returns `undefined`. Without `grace`, every compatible caller takes the negative disposition even if it observed a stale value. Validation may instead throw `RegistryUnavailableError` under the configured unavailable posture. Before persistence, every negative attempt passes through the L2 negative-write guard. A decodable existing value whose invalidation verdict is fresh or stale—or cannot be established—refuses the write. The not-found disposition still surfaces `undefined` from origin with `durable: false`, while the existing L2 value and L1 state remain unchanged. Codec-incompatible, undecodable, or dead L2 bytes do not trigger this guard; normal newer-envelope arbitration still applies afterward. A negative result that reaches the serve path skips L1 hydration and triggers best-effort deletion of any older L1 copy unless the guard refused the write. This cleanup still runs after a classified retryable write failure. Both a stale-candidate suppression and a guard refusal emit `neg_suppressed`. ### Factory context [Section titled “Factory context”](#factory-context) | `FactoryCtx` member | Meaning and current boundary | | -------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | | `signal` | A fresh `AbortSignal`. The kernel does not yet abort it on a cache deadline. | | `graced` | Optional `GracedInfo` describing a stale candidate. It is not populated today. | | `request` | The adapter request object. Under `dev: true` and explicit public scope, property reads can demote the fill to L1-only. | | `fail(err?)` | Throws a factory failure without pretending it is a returned `T`. | | `dependsOn(tag, options?)` | Adds a membership to the final stored tag set and the active render collector. `{ l3: false }` currently throws `NotImplementedError`. | | `setTags(tags)` | Authors a replacement for the call-level tag base. `dependsOn()` memberships remain unioned independently. | | `setTtl(ttl)` | Declared factory-time TTL override; currently throws `NotImplementedError`. | | `reuseGraced()` | Intended typed reuse of the graced value after provenance checks; currently throws `NotImplementedError`. | The final settle-time union may contain at most 126 distinct user tags. Invalid, reserved, or excessive tags fail before storage. Calling `dependsOn()` or `setTags()` after the factory promise settles throws `FactorySettledError`. `EntryFactoryCtx` adds `skip(): never`. Calling it produces a skipped entry and stores nothing. The plain `FactoryCtx` deliberately has no `skip()`. Related exports are `FactoryCtx`, `EntryFactoryCtx`, `GracedInfo`, and `RequestContext`. `GracedInfo` exposes the candidate’s unknown-typed `value`, source identifier `src`, schema version `v`, and original `bornMs`. It is metadata for `reuseGraced()`, not permission to cast the unknown value to `T`. ### `CacheEntry` [Section titled “CacheEntry\”](#cacheentryt) | Field | Meaning and current boundary | | --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `value` | The value, or `undefined` for a miss, skip, or negative entry. A fresh negative keeps the tier, age, and durability evidence of the entry that supplied that fact. | | `skipped` | The entry factory called `skip()`. | | `stale` | The returned value was not fresh at this read’s consistency level. | | `age` | Whole milliseconds since the served envelope’s `bornMs` fill-start timestamp, measured with the injected Clock and clamped at zero. A miss or skip reports zero. This is observability, not TTL enforcement. | | `tier` | `l1`, `l2`, `origin`, or `miss`. The exported `Tier` union also declares `l1.5`, which is not emitted. | | `servedOnError` | Present when a classified transient failure reused a revalidated stale candidate. | | `durable` | Optional serve evidence. Every newly filled origin result reports whether L2 accepted what that turn produced or a newer durable entry won. A refused negative and a suppressed Store write report `false`; an L2 hit reports `true`; a principal-scoped L1 hit reports `false`. A public or tenant L1 hit omits the field because that serve does not consult L2. | ## Invalidate values [Section titled “Invalidate values”](#invalidate-values) ### Selector types [Section titled “Selector types”](#selector-types) `ExpireSelector` accepts `{ tag, scope? }` or `{ key }`. `DeleteSelector` accepts those shapes plus an optional `cdn` mode. * A tag selector affects every entry carrying that tag. The current implementation ignores its optional `scope` field. * A key selector targets the contextless public canonical key only. Use a dependency tag when tenant or principal variants may exist. * `CdnMode` is `“enqueue” | “await” | “block”`, but no mode invokes the configured CDN today. `OriginLoadGuard` is the explicit `{ iUnderstandTheOriginLoad: true }` acknowledgement required by the unimplemented `expireAll()` and `deleteAll()` methods. ### `PurgeResult` [Section titled “PurgeResult”](#purgeresult) | Field or method | Meaning and current boundary | | -------------------------- | ---------------------------------------------------------------------------------------------- | | `epoch` | Registry epoch returned for the mutation. | | `matchedHint` | Best-effort `MatchedHint`: `yes`, `no-such-scope`, or `unknown`. It is always `unknown` today. | | `flushed({ timeout? })` | Intended durable, Bus, and mirror-acceptance completion. Resolves immediately today. | | `edgePurged({ timeout? })` | Intended CDN-acceptance completion. Resolves immediately without a CDN purge today. | See [Invalidate cached data](/docs/cache/tags-and-invalidation/) for safe mutation order and [Implementation status](/docs/cache/api-status/) before using completion fields operationally. ## Build tags and durations [Section titled “Build tags and durations”](#build-tags-and-durations) ### Tag exports [Section titled “Tag exports”](#tag-exports) | Export | Purpose and current boundary | | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Tag` | Branded string accepted by cache reads and invalidation selectors. | | `TagPart` | `string \| number` input used by tag helpers. | | `compound(…parts)` | Implemented positional builder. Escapes `%`, `:`, and`` | | `t` | Implemented tagged-template trust boundary. Validates a non-empty, non-reserved final tag against the lowercase grammar and 256 UTF-8-byte ceiling; it rejects rather than escaping interpolations. | | `globalTag(name)` | Declared globally scoped tag helper; currently throws `NotImplementedError`. | User tags beginning with `__` are reserved and rejected with `InvalidTagError` at the cache boundary. ### Duration exports [Section titled “Duration exports”](#duration-exports) `Duration` is a template-literal type such as `“250ms”`, `“5m”`, or `“1.5h”`. `DurationUnit` is `“ms” | “s” | “m” | “h” | “d”`; `m` means minutes. `duration(value, unit)` is the implemented computed-value helper. It rejects non-positive or non-finite values and any multiplied millisecond result that is not a finite, positive, safe integer, throwing `InvalidDurationError`. Duration strings can appear in typed options, although elapsed TTL and grace behavior is unfinished. ## Driver contracts [Section titled “Driver contracts”](#driver-contracts) Most application developers should receive drivers from a runtime package. These exports exist for adapter authors, test harnesses, and advanced integrations. ### Storage [Section titled “Storage”](#storage) The core Store shape is: ```ts interface Store { get(key: string, readKind?: ReadKind): Promise set( key: string, value: string, options?: StoreWriteOptions, ): Promise delete(key: string): Promise } ``` | Export | Purpose | | -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Store` | Async `get()`, `set()`, and `delete()` contract used by L1, L2, and mirror storage. | | `ReadKind` | Optional Store read hint: `“pointer”`, `“delta”`, or `“snap”`. Drivers may map it to different read-cache policy. | | `StoreValue` | Stored string value plus optional metadata. | | `StoreMetadata` | Readonly metadata record. | | `StoreWriteOptions` | Optional physical `expirationTtl` in seconds and metadata. | | `StoreWriteError` | Structural write rejection with code `throttled`, `too_large`, or `unavailable`, plus retryability and optional cause. | | `isStoreWriteError(value)` | Implemented shape-based type guard for `StoreWriteError`. | | `MemoryOptions` | Optional `clock`, `maxEntries`, UTF-8 `maxBytes`, `telemetry`, and `onSinkError` for the local Store. | | `memory(options?)` | Implemented per-instance LRU Store. It evicts to both configured bounds, rejects a single oversize value as `too_large`, honors Store-level `expirationTtl` with a Clock, and can report pressure eviction of principal-scoped entries without identifiers. | Calling `Store.set()` with `expirationTtl` on a clockless memory Store fails loudly instead of silently ignoring residency. The Cache kernel does not currently pass value TTL through as Store residency, so this behavior matters primarily to direct Store users and replication objects. Serving reads recognize the following structural rejection: ```ts interface ClassifiedStoreReadFailure { readonly code: "throttled" | "unavailable" readonly retryable: boolean readonly cause?: unknown } ``` The shape and its guard are deliberately not root exports, but classification is shape-based rather than `instanceof`-based. A custom Store can therefore reject `get()` with that exact shape; the bundled `cloudflareKV()` driver provides the covered implementation. A classified failure emits `store_read_suppressed` and acts as a tier miss, while any rejection without both a recognized `code` and boolean `retryable` propagates unchanged. `explain()` reports `read-failed` when a classified failure prevents it from proving either presence or absence. `CasOrder` contains `epoch` and `fence`. `CasRecord` adds an optimistic-concurrency `token`. `CasStore` declares `seed(key, order)`, `load(key)`, and `swap(key, expectedToken, next)`. It is an optional atomic compare-and-set capability for durable drivers; the current kernel does not consume it. ### Invalidation coordination [Section titled “Invalidation coordination”](#invalidation-coordination) | Export | Purpose | | -------------- | ------------------------------------------------------------------------------------ | | `Registry` | Registry identity, live checks, soft and hard mutations, and retention registration. | | `RegistryAck` | Mutation acknowledgement containing the accepted epoch. | | `TagChange` | One tag’s optional soft and hard watermark changes. | | `BusFrame` | A contiguous `fromEpoch` to `toEpoch` range of changes. | | `BusEvent` | `frame`, `gap`, `reset`, `hello`, or `lost` event delivered to a subscriber. | | `Bus` | Subscribes the kernel to Bus events. The kernel validates continuity. | | `Subscription` | Handle with `close()`. | The Registry is authoritative; the Bus is a delivery mechanism. A gap or reset suspends warm trust until recovery establishes a verified position. The `Registry` contract exposes: | Member | Meaning | | -------------------------------- | -------------------------------------------------------------------------- | | `regId` | Stable Registry identity used to scope and verify recovery-mirror objects. | | `check(tags)` | Returns live `TagKnowledge` for each requested tag. | | `expire(tags)` | Advances soft watermarks and returns a `RegistryAck`. | | `delete(tags)` | Advances hard watermarks and returns a `RegistryAck`. | | `registerRetention(retentionMs)` | Registers this instance’s maximum retention. | `RegistryAck` contains the accepted `epoch`. `TagChange` contains a `tag` plus optional `softEpoch` and `hardEpoch`. `BusFrame` contains `fromEpoch`, `toEpoch`, and a list of changes. `BusEvent` is one of: * `{ kind: “frame”, frame }` for a contiguous change frame; * `{ kind: “gap”, head }` when delivery loss is known and the transport declares the minimum head the reader must reach; * `{ kind: “reset” }` when the transport is re-established; * `{ kind: “hello”, head }` immediately after establishment, declaring the live channel’s current head; * `{ kind: “lost” }` when a live transport is unavailable, whether it never established or was later lost. ### Other capabilities [Section titled “Other capabilities”](#other-capabilities) | Export | Purpose and current boundary | | ------------ | --------------------------------------------------------------------- | | `Clock` | `now(): number`, the kernel’s explicit logical-time source. | | `Rng` | `next(): number`, the kernel’s explicit random source in `[0, 1)`. | | `Codec` | Wire identity plus synchronous `encode()` and `decode()`. | | `Lock` | Acquires a `LockHandle` for a canonical key. | | `LockHandle` | Carries a monotone `fence` and async `release()`. | | `Cdn` | Accepts a set of tags and a `CdnMode`. Declared but not called today. | | `CdnMode` | CDN acceptance request: `enqueue`, `await`, or `block`. | See [Driver implementations](/docs/cache/drivers-and-status/) for available implementations. ## Consistency and stored data [Section titled “Consistency and stored data”](#consistency-and-stored-data) | Export | Meaning | | --------------- | ----------------------------------------------------------------------------------------------- | | `Consistency` | `eventual` or `strong`. | | `Watermark` | Monotone `softEpoch` and `hardEpoch` for a tag. | | `TagKnowledge` | Either known watermarks with a verified `throughEpoch`, or `{ known: false }`. | | `Validity` | Validation result: `fresh`, `stale`, `dead`, or `unknown`. | | `UnknownPolicy` | `registry-check`, `miss`, or the throwing `error` policy. | | `ChannelState` | Reader-observed live-channel state: `never-established`, `established`, or `lost`. | | `Scope` | Explicit `public` or tenant scope. An omitted scope may derive a principal-local storage class. | | `Tier` | Result tier: `l1`, declared `l1.5`, `l2`, `origin`, or `miss`. | `Envelope` is the exported schema-v3 stored entry. Most applications should not construct envelopes directly. | Field | Meaning | | -------------- | ----------------------------------------------------------------------------------------------------------------------------------- | | `v` | Literal schema version `3`. | | `key` | Canonical namespace-version, namespace, scope, and user-key string. | | `val` | Stored value after decoding. For `kind: “neg”`, the decoded placeholder is internal and a serving read returns `undefined` instead. | | `bornEpoch` | Invalidation epoch captured at fill start. | | `bornMs` | Fill-start time for TTL, grace, age, and retention arithmetic—not invalidation ordering. | | `storedAt` | Observability timestamp. | | `ttl`, `grace` | Stored timing fields. They are zero in current entries. | | `tags` | Sorted, deduplicated user and reserved dependency tags. | | `scope` | Resolved public, tenant, or principal storage class. | | `src` | Source identifier. | | `kind` | `EnvelopeKind`: `val` or negative `neg`. | | `enc` | `EnvelopeEnc`: `json`, `json+gz`, or `bin`. | | `size` | Stored payload size. | | `codecId` | Wire identity checked before decoding. | | `fence` | Optional Lock fencing token. | ## Render collection and L3 [Section titled “Render collection and L3”](#render-collection-and-l3) | Export | Purpose and current boundary | | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------ | | `RenderCollector` | Records dependency tags and makes an eligibility decision at header commit. | | `L3Budget` | Optional `maxBytes` and `maxTags` limits. | | `L3Emission` | Eligibility, emitted cache tags, and optional ineligibility reason. | | `L3Ineligibility` | `late-tag`, `budget`, `scope`, or `scope-unreadable`. | | `Explanation` | Three-arm `kind`-discriminated witness returned by `explain()`: `present`, `absent`, or `read-failed`. | | `ExplainIdentity` | Stored tags, scope evidence, birth epoch, TTL evidence, and entry kind. | | `ExplainVerdict` | Current local validity plus the soft and hard epochs behind it. | | `ExplainReaderState` | Applied epoch, suspicion state, terminal recovery state, and `ChannelState`. | | `ExplainScope`, `ExplainTtl` | Readable-versus-unreadable scope and stored-versus-not-stored TTL evidence. | | `ExplainedDependency`, `ExplainDependencyScope` | Request-scoped render attribution and its scope evidence. | The collector and React Router header integration are implemented. The CDN purge path remains incomplete. `RenderCollector.dependsOn(tag, { l3? })` records a render-only dependency with no managed scope claim. Setting `l3: false` excludes its tag from emission, timing, and budget checks. `commitHeaders({ maxBytes?, maxTags? })` returns an `L3Emission`. Automatically recorded hits and fills carry scope evidence; any non-public or unreadable scope makes the emission ineligible and empty. `explain(key)` probes L1 then L2 without hydrating L1, live-checking the Registry, or resynchronizing the reader. It addresses the default public canonical key because the signature has no scope input. Every arm contains `key`, `reader`, and optional request-scoped `requestDependencies`. The `present` arm adds `tier`, `identity`, and `verdict`; the proven `absent` arm reports `tier: “miss”`; `read-failed` has no tier or identity because at least one Store did not answer and no other tier supplied the entry. Current entries report TTL as `{ kind: “not-stored” }`. ## HTTP failures and errors [Section titled “HTTP failures and errors”](#http-failures-and-errors) ### HTTP helpers [Section titled “HTTP helpers”](#http-helpers) `httpError(response)` returns an `HttpError` carrying the response and status. `isRetriableHttp(error)` is the implemented default stale-on-error classifier: it accepts network `TypeError` values, cache-originated timeouts, selected transient HTTP statuses, and Cloudflare 52x/530 statuses. It does not classify caller aborts or fact-like 403, 404, and 410 responses as retriable. ### Error exports [Section titled “Error exports”](#error-exports) | Export | Meaning and current boundary | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------- | | `HttpError` | Typed non-success HTTP response with `status` and `response`. | | `CacheTimeoutError` | Timeout carrying a `CacheTimeoutSource` of `cache` or `caller`. The kernel does not currently create cache-deadline aborts. | | `FencedError` | A plain-value fill was fenced by a conflicting hard invalidation, leaving no value to return. | | `InvalidTagError` | Caller supplied a malformed or reserved tag at the cache boundary. | | `InvalidDurationError` | `duration()` received a non-positive, non-finite, fractional-millisecond, or unsafe computed duration. | | `FactorySettledError` | A retained factory context declared tags after its factory promise settled. Carries the offending key. | | `NotImplementedError` | A declared preview surface was called before implementation. | | `RegistryUnavailableError` | A strong Registry check failed and the call was not configured to degrade to eventual. | | `UnknownTagError` | Unknown invalidation knowledge reached the `error` posture. Carries the affected user tags. | ## Telemetry [Section titled “Telemetry”](#telemetry) | Export | Meaning | | ---------------------- | ------------------------------------------------------------------------------------------------------------ | | `TelemetryEvent` | Event name in `type` plus event-specific fields. | | `TelemetrySink` | Function receiving each emitted event. | | `TelemetryEventName` | Union of all values in the event catalog. | | `TELEMETRY_EVENTS` | Public event-name catalog for kernel, memory, React Router, and Cloudflare events, including reserved names. | | `StrongDegradedReason` | Current reason union for `strong_degraded`: `registry_unreachable`. | | `SinkErrorHook` | Optional observer for a sink failure that built-in delivery swallowed. | | `TelemetryConfig` | Sink plus optional `hosted` flag, project `salt`, and `onSinkError`. | A plain sink may receive raw identifiers. Hosted mode with a salt HMAC-pseudonymizes emitted string fields except the structural event type. Hosted mode without a salt suppresses events rather than forwarding raw strings. Built-in delivery swallows synchronous throws and asynchronous rejections from the sink; it also guards the optional error hook. ## Cloudflare adapter exports [Section titled “Cloudflare adapter exports”](#cloudflare-adapter-exports) Import these names from `@astilba/cache/cloudflare`. The subpath resolves only in a Workers-compatible runtime because `Coordinator` uses `cloudflare:workers`. | Export | Purpose and current boundary | | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `createWorkersCache(config)` | Composes a Workers Clock and Rng, bounded memory L1, KV L2, named Coordinator Registry, lazily redialed Bus, and a request-driven recovery carrier. Construction performs no I/O. | | `WorkersCacheConfig` | Requires `name`, `kv`, and `coordinator`; accepts optional `defaults`, kernel `telemetry`, and `takedownSensitive`. | | `Coordinator` | Durable Object class the Worker must export and bind with a SQLite lifecycle declaration. Prefer the top-level `exports` map for a new Worker; legacy `migrations` remain supported. Its environment requires `REGISTRY_KV` and accepts Registry heartbeat and snapshot tuning variables. | | `cloudflareKV(namespace)` | Builds the Cloudflare KV Store driver. | | `doRegistry(source, regId?)` | Builds the thin Coordinator RPC Registry from a stub or a thunk that mints one. The Registry ID must match the named Durable Object identity. | | `StubSource` | A Coordinator stub or a zero-argument stub factory. Use the thunk form when the Registry outlives a request. | | `doBus(dial, options)` | Builds a mechanism-only WebSocket Bus client. It reports closure but does not reconnect itself. | | `Dial` | Function returning a compatible client socket synchronously or asynchronously. | | `DoBusOptions` | Requires `regId` and accepts an `onClose` status callback. | | `DoBusCloseInfo` | Close code, reason, and whether the client initiated the closure. | | `redialingDoBus(dial, options)` | Wraps `doBus()` with jittered exponential reconnection whose due attempts are performed by request-time ticks, never platform timers. | | `RedialOptions` | Registry identity, injected Rng, and optional close callback, base delay, and jitter fraction. | | `RedialingBusHandle` | A Bus with `tick(nowMs)`; a due tick performs one lazy redial and otherwise does nothing. | | `InvalidRegistryNameError` | A named Coordinator identity violates the lowercase `[a-z0-9._-]`, 1–64-character Registry grammar. | See [Cloudflare Workers](/docs/cache/cloudflare-workers/) for the binding relationship and operational limits. ## React Router adapter exports [Section titled “React Router adapter exports”](#react-router-adapter-exports) Import these names from `@astilba/cache/react-router`. React and React Router are optional peer dependencies so root and Cloudflare-only consumers do not need them. | Export | Purpose and current boundary | | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `cacheMiddleware(options)` | Creates React Router v8 server middleware that provides Cache, opens request and render frames, triggers poll ticks, and commits scope-safe response headers. | | `CacheMiddlewareOptions` | Requires `cache`; accepts synchronous request identity derivation, `waitUntil`, telemetry, `onSinkError`, and an L3 budget override. | | `CacheMiddlewareArgs` | The argument object React Router passes to server middleware, re-exported for identity mappers. | | `cacheContext` | Typed Router context key. Loaders and actions read the request’s Cache with `context.get(cacheContext)`. | | `currentRequest()` | Returns the current AsyncLocalStorage-backed `RequestContext`, or `undefined` outside the middleware frame. | | `POLL_TICK_FAILED` | The `“poll_tick_failed”` telemetry event name emitted when out-of-band recovery work rejects. | | `TICK_MIN_INTERVAL_MS` | One-second minimum between middleware request-entry ticks for the same Cache instance. | | `L3_INELIGIBLE` | The `“l3_ineligible”` event name emitted once for a demoted managed response. | | `L3_BUDGET_DEFAULT` | Default 16 KB and 1,000-occurrence response-tag budget. | On Cloudflare Workers, the package requires a compatibility date of 2024-09-23 or later and the `nodejs_compat` flag: the root uses `node:crypto`, and that flag also supplies the AsyncLocalStorage support used by this adapter. `nodejs_als` alone is insufficient. The middleware preserves an application-authored cache policy for eligible public renders, emits deduplicated user tags, and forces private posture for unsafe renders. It never writes `public` or `s-maxage`. See [React Router](/docs/cache/react-and-server-apps/). ## Export boundary [Section titled “Export boundary”](#export-boundary) The publish configuration contains four supported doors: the root entry point, `./cloudflare`, `./react-router`, and `./package.json`. Deep source paths are not public APIs. The source workspace also exposes `@astilba/cache/registry` so its test harness and Coordinator can share the state machine. The publish configuration deliberately omits it; applications must not import that source-only subpath. For implementation gaps, inert fields, and integration availability, continue to [Implementation status](/docs/cache/api-status/). # Driver implementations > See which Cache drivers and runtime adapters exist in current source and which production boundaries remain open. Astilba Cache keeps its semantics behind small contracts, then implements platform I/O at adapter boundaries. This page separates an implemented source driver from a released, supported integration. Application developers can begin with [Cache fundamentals](/docs/cache/core-concepts/). Runtime authors and production-readiness reviewers should use this page together with [Implementation status](/docs/cache/api-status/). ## Understand the driver model [Section titled “Understand the driver model”](#understand-the-driver-model) | Contract | Role | | -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Store` | Key/value I/O for local L1, shared L2, and replication-mirror objects. An optional `ReadKind` lets a driver choose different read-cache policy for a mutable pointer and immutable deltas or snapshots. | | `Registry` | The authoritative record used for live tag checks and soft or hard mutations. Its `regId` scopes recovery objects. | | `Bus` | Delivers ordered frames plus reset, hello, gap, and lost signals. The kernel—not the transport—validates continuity and records channel state. | | `Lock` | Optional cross-isolate exclusion with a monotone fence token. | | `Codec` | Value encoding plus a wire identity checked before decode. | | `Cdn` | Planned edge-purge queue boundary; not wired today. | | `Clock`, `Rng` | Explicit time and randomness for portable, deterministic kernel behavior. | The base Store shape is small: store.ts ```ts interface Store { get(key: string, readKind?: ReadKind): Promise set( key: string, value: string, options?: StoreWriteOptions, ): Promise delete(key: string): Promise } ``` Classified writes use the public structural `StoreWriteError` shape. `throttled` and `unavailable` failures may leave a successful fill at `durable: false`; `too_large` is permanent and propagates rather than truncating the value. Serving-path reads also recognize structural `throttled` and `unavailable` failures, but that read-error shape is intentionally internal rather than a root export. A classified failure emits `store_read_suppressed` and acts as a tier miss; an unclassified error propagates unchanged. In `explain()`, the same classified failure becomes `read-failed` when no other tier finds the entry, preserving the distinction between “nothing stored” and “Store did not answer.” ## Check each implementation [Section titled “Check each implementation”](#check-each-implementation) | Surface | Current source status | What is covered | | --------------------------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Portable kernel and contracts | Implemented | Deterministic invariant, scenario, unit, and conformance lanes cover the public read, fill, invalidation, scope, codec, recovery, and failure behavior. | | `memory()` | Implemented root export | Per-instance LRU Store with `maxEntries` and UTF-8 `maxBytes` limits. With an injected Clock it also honors Store-level `expirationTtl`. | | `cloudflareKV()` | Unreleased source preview | KV-backed Store with metadata round trips, a 25 MiB value ceiling, read and write classification, 60-second minimum write residency, and intent-specific mirror read-cache hints. | | `Coordinator` | Unreleased source preview | SQLite-backed Durable Object host for Registry RPC, a replayable command journal, coalesced flush alarms, mirror deltas and snapshots, and WebSocket fan-out. | | `doRegistry()` | Unreleased source preview | Thin Registry RPC client scoped to the named Coordinator. It accepts either a stub or a per-use stub thunk and passes the Registry contract under workerd. | | `doBus()` | Unreleased source preview | Mechanism-only WebSocket Bus client with wire validation, Registry identity checks, and close reporting. | | `redialingDoBus()` | Unreleased source preview | Schedules DO Bus reconnection with injected jitter and exponential backoff capped at 300 seconds; a due `tick(nowMs)` performs the redial without a platform timer. | | Replication reader | Implemented kernel path | Replays contiguous deltas, escalates persistent holes to a pointer-blessed snapshot, replays the tail, and remains fail closed on corrupt or unfillable chains. | | Replication poller | Implemented internal seam | Runs baseline pointer observation, bounded recovery retries, snapshot escalation, and failure backoff from externally supplied ticks. | | `createWorkersCache()` | Unreleased source preview | Composes the Workers Clock/Rng, memory L1, KV L2, lazily minted named Coordinator Registry handles, tick-redialed Bus, and an unawaited read-triggered recovery carrier. Construction performs no I/O. | | React Router middleware | Unreleased source preview | Supplies Cache through typed Router context, carries request identity, starts request-entry poll ticks with optional `waitUntil` adoption, collects served dependencies, emits eligible `Cache-Tag` headers, and demotes unsafe responses. | | Local chaos demo | Source-only evidence app | Runs the real Workers composition against local KV and Coordinator bindings. Demo-owned wrappers inject three binding failures and the UI compares observed behavior with documented permissions without asserting an SLO. | | Composed demo boot witness | Required source CI lane | Builds and boots the emitted demo Worker on local workerd with its real Coordinator and KV bindings. Bounded polling requires a healthy channel to reach `established` and, after arming the fault, a fresh scene-owned reader to report `never-established` plus `bus_dial_failed`. Probe failure remains distinct from a channel verdict. This is a composition witness, not a deployed measurement or kernel-semantics test. | | Redis, production Lock, and CDN drivers | Not implemented | Contracts exist, but no package subpaths or production implementations are present. | “Unreleased source preview” means the symbol is present in the package export map and publish configuration in the reviewed private source snapshot. It does not mean consumers can inspect or install it. npm still has no `@astilba/cache` package. ## Understand Cloudflare-specific behavior [Section titled “Understand Cloudflare-specific behavior”](#understand-cloudflare-specific-behavior) The Cloudflare path uses one named Coordinator identity for the Durable Object address, Registry scope, and Cache namespace. The KV binding used by `createWorkersCache()` must expose the same namespace that the Coordinator receives as `REGISTRY_KV`; otherwise the reader and writer see different recovery mirrors. The KV Store uses different **read-cache hints** for recovery objects: * mutable pointer reads use the platform’s 30-second minimum, reduced from 60 seconds in January 2026; * immutable deltas and snapshots use 24 hours; * ordinary value reads do not supply a hint and inherit the platform default. These read hints are distinct from physical write residency. The Coordinator writes immutable delta batches with a 48-hour `expirationTtl`, snapshots with seven days, and its mutable pointer with no expiration. The KV driver floors any supplied write residency to Cloudflare’s 60-second minimum. See Cloudflare’s [reduced minimum cacheTtl announcement](https://developers.cloudflare.com/changelog/post/2026-01-30-kv-reduced-minimum-cachettl/) for the read-cache change and [KV write API](https://developers.cloudflare.com/kv/api/write-key-value-pairs/) for write residency, value size, and rate limits. The Coordinator can refresh an idle pointer through `REGISTRY_HEARTBEAT_MS`. This is disabled unless the deployment sets the variable. The Workers factory independently defaults its reader heartbeat interval to 30 seconds, so matching the Coordinator value is an operator action, not an automatic handshake. Workers deployments must use a compatibility date of 2024-09-23 or later and enable `nodejs_compat`. The root package uses `node:crypto`, and that flag also supplies the AsyncLocalStorage support needed by React Router. `nodejs_als` alone is insufficient. See [Cloudflare Workers](/docs/cache/cloudflare-workers/) for the binding and lifecycle example. ## Know the recovery scheduling model [Section titled “Know the recovery scheduling model”](#know-the-recovery-scheduling-model) The kernel poller contains no timer. A runtime hands it ticks so portable code never reads ambient time or schedules work. The Workers factory fires an unawaited tick from `getOrSet()` or `getOrSetEntry()`, at most once per second per Cache instance. That carrier first performs any due Bus redial, then offers the same instant to the poller. The React Router adapter additionally fires a tick at request start and can pass it to `waitUntil`. The shared poller applies its longer baseline cadence and bounded retry schedule, so multiple offers do not imply duplicate I/O. Failed tick work is swallowed and can emit `poll_tick_failed` telemetry. A raw runtime that uses neither the Workers factory nor React Router still has reactive recovery: a suspect read performs one bounded fast resync attempt. It does not receive proactive baseline polls unless its embedding drives the internal tick seam. ## Keep the release boundary visible [Section titled “Keep the release boundary visible”](#keep-the-release-boundary-visible) The Workers path still needs work before a production release: * elapsed TTL, grace, and negative-entry expiry—entry age is measured, but it does not enforce timing policy; * Coordinator journal checkpointing and truncation; * a CDN purge queue and completion promises that track real acceptance—the response adapter now emits safe tags, but does not deliver purges; * a production Lock driver and the deferred Redis/Valkey path; * deployed consistency, propagation, caching, and production-threshold measurements behind the local chaos demo; * an npm release, compatibility policy, deployment guide, and upgrade process. The integration Worker and React Router fixture prove runtime wiring and build compatibility. The required built-demo witness adds one local composition check for healthy and refused Bus establishment. The chaos demo remains a local evidence app, not a production template or an SLO. Do not import deep adapter files. Use only the root, `./cloudflare`, and `./react-router` entry points documented here. ## Related [Section titled “Related”](#related) * [Runtime architecture](/docs/cache/architecture/) shows how these capabilities compose around one Cache instance. * [Cloudflare Workers](/docs/cache/cloudflare-workers/) provides the current factory and binding walkthrough. * [React Router](/docs/cache/react-and-server-apps/) explains request context and poll ticks. * [Cache HTTP responses](/docs/cache/response-caching/) explains automatic render collection and header behavior. * [Inspect cache behavior](/docs/cache/observability/) covers driver and adapter telemetry. * [API reference](/docs/cache/api-reference/) lists the root and adapter exports. * [Implementation status](/docs/cache/api-status/) lists kernel-level limitations independent of a driver.