<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: cilly</title>
    <description>The latest articles on DEV Community by cilly (@cilly).</description>
    <link>https://dev.to/cilly</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4041900%2F8ea21a05-5cb2-4ce8-a544-8c786dc82bb4.png</url>
      <title>DEV Community: cilly</title>
      <link>https://dev.to/cilly</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cilly"/>
    <language>en</language>
    <item>
      <title>Disposable isolated dev environments with Claude Code worktree hooks</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Mon, 10 Aug 2026 05:01:38 +0000</pubDate>
      <link>https://dev.to/cilly/disposable-isolated-dev-environments-with-claude-code-worktree-hooks-2ogn</link>
      <guid>https://dev.to/cilly/disposable-isolated-dev-environments-with-claude-code-worktree-hooks-2ogn</guid>
      <description>&lt;p&gt;Once you start working with coding agents, the unit of work shifts from "one branch a day" to "three or four tasks at once." The agent moves on to the next task without waiting, so the bottleneck on the human side becomes &lt;strong&gt;workspaces&lt;/strong&gt;. If everything fights over a single checkout, all that parallelism dies.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git worktree&lt;/code&gt; solves half of this problem. It grows multiple working trees from the same repository, so the &lt;strong&gt;code&lt;/strong&gt; is isolated. But a dev environment doesn't run on code alone. The DB, ports, dev servers, containers — all of these stay shared no matter how many worktrees you create. Apply a migration in worktree A and the app in worktree B breaks. Start dev servers in both and they fight over the port. &lt;code&gt;git worktree&lt;/code&gt; by itself does not give you parallel development.&lt;/p&gt;

&lt;p&gt;So I built a mechanism that &lt;strong&gt;spawns a dedicated environment (DB, ports, processes) per worktree and manages its lifecycle automatically through Claude Code hooks&lt;/strong&gt;. One command brings up the environment together with the worktree when a task starts; closing the session stops the environment; merging leaves no trace. Disposable, isolated environments.&lt;/p&gt;

&lt;p&gt;I've been running this on three projects with different constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Project A&lt;/strong&gt;: a monorepo — dedicated DB container + frontend dev server per worktree&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project B&lt;/strong&gt;: a monorepo — runs on a local emulator suite (with the constraint that its ports are fixed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Project C&lt;/strong&gt;: multiple repositories (polyrepo) — isolation has to span repos&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Despite the differing constraints, the architectures converged to the same shape. This article is that converged design — the division of roles between hooks, skills, and the CLI, plus the design decisions that solidified in operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big picture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcilly-yllic.github.io%2Fimages%2Fworktree-isolated-env-architecture-en.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fcilly-yllic.github.io%2Fimages%2Fworktree-isolated-env-architecture-en.png" alt="Architecture diagram: three entrances go through a single CLI to operate per-worktree environments, with hooks managing the lifecycle" width="800" height="400"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Whatever the entrance, the substance is one CLI. Hooks connect lifecycle events to the CLI, and the dedicated environment never touches the main one.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There are only three kinds of actors.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The CLI script&lt;/strong&gt; … a shell script holding all the real work — create, start, stop, destroy (deterministic, idempotent)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hooks&lt;/strong&gt; … connect Claude Code lifecycle events (worktree creation, session start / end) to the CLI&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A skill&lt;/strong&gt; … translates natural-language requests inside a session into CLI commands&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The rest of this article walks through five design principles that settled in operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 1: put the real work in a deterministic CLI; the skill is only a translator
&lt;/h2&gt;

&lt;p&gt;This is the most important decision. &lt;strong&gt;Never let the LLM execute environment orchestration — creation, allocation, startup, teardown — as a "procedure."&lt;/strong&gt; Everything is materialized in one shell script (a few hundred lines), and the skill's job is narrowed to "translate the request into a command, run it once, report the result."&lt;/p&gt;

&lt;p&gt;The skill definition (SKILL.md) is essentially this mapping table:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;What the user says&lt;/th&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;"Set up the environment for TASK-1234"&lt;/td&gt;
&lt;td&gt;&lt;code&gt;up 1234&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"I want to verify with the backend too"&lt;/td&gt;
&lt;td&gt;&lt;code&gt;up 1234 --with-backend --clone-db&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Pause it for now" (don't delete)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;stop 1234&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Resume the one from earlier"&lt;/td&gt;
&lt;td&gt;&lt;code&gt;start 1234&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"Tear down this worktree"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;remove 1234&lt;/code&gt; (destructive — confirm first)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;"List them" / "What's the status?"&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;list&lt;/code&gt; / &lt;code&gt;status&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;If you let an agent improvise git and npm commands, the procedure differs subtly every time, and so does the state after a failure. Pushing everything into a script buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency and reproducibility&lt;/strong&gt; … same input, same result; safe to re-run after a mid-way failure&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent independence&lt;/strong&gt; … run it straight from a terminal with no LLM and no tokens; usable from CI or other agents&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token savings&lt;/strong&gt; … even via the skill, the model only reasons about "which command"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Testability&lt;/strong&gt; … if &lt;code&gt;source&lt;/code&gt;-ing the script loads only function definitions, allocation logic can be verified in isolation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The skill explicitly states: "do not re-type individual git commands by hand (the script is the single source of truth)." LLMs are clever; left alone they invent workaround procedures that bypass the script. That one line is the brake.&lt;/p&gt;

&lt;p&gt;The three entrances are a corollary of this design. Because the substance is a CLI, calling it (1) from a launcher, (2) from a terminal, or (3) via natural language in a session all produce identical behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 2: session = the environment's lifetime (SessionStart / SessionEnd)
&lt;/h2&gt;

&lt;p&gt;When humans manage environment lifecycles, leftover running environments always pile up. So I tilted it to "&lt;strong&gt;the environment lives exactly as long as the Claude Code session&lt;/strong&gt;."&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SessionStart hook&lt;/strong&gt; … if cwd is a managed worktree, auto-&lt;code&gt;start&lt;/code&gt; the environment if stopped&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SessionEnd hook&lt;/strong&gt; … likewise, auto-&lt;code&gt;stop&lt;/code&gt; (data persists in volumes; the next start resumes with no diff)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;code&gt;cd worktree &amp;amp;&amp;amp; claude&lt;/code&gt; wakes the environment, and &lt;code&gt;/exit&lt;/code&gt; cleans up after itself. "Forgot to stop it" and "forgot to start it" disappear structurally.&lt;/p&gt;

&lt;h3&gt;
  
  
  SessionStart's stdout is injected into context
&lt;/h3&gt;

&lt;p&gt;SessionStart has a crucial property: &lt;strong&gt;its stdout (or &lt;code&gt;additionalContext&lt;/code&gt;) is injected into the session's context.&lt;/strong&gt; I use it to push connection info in right at startup.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"systemMessage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"[wt] environment resumed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"hookSpecificOutput"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"hookEventName"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SessionStart"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"additionalContext"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FE: http://localhost:60398 / DB: localhost:55231 (user/pass...)"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both Claude and the human start the session &lt;strong&gt;already knowing&lt;/strong&gt; the URLs and DB connection info. No more re-discovering "where is the dev server running?" every time. With dynamic port allocation (below), URLs change every run, so this injection matters even more.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trap: SessionEnd fires on &lt;code&gt;/clear&lt;/code&gt; too
&lt;/h3&gt;

&lt;p&gt;One trap from operation: &lt;code&gt;/clear&lt;/code&gt; (clearing context) also fires SessionEnd, immediately followed by SessionStart. If you naively stop there, &lt;strong&gt;the environment goes down mid-work&lt;/strong&gt; and has to boot again. The hook's input JSON carries a &lt;code&gt;reason&lt;/code&gt;, so branch on it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;REASON&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INPUT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.reason // empty'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REASON&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"clear"&lt;/span&gt; &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;0   &lt;span class="c"&gt;# do not stop on clear; the session continues&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Principle 3: take over creation itself with the WorktreeCreate hook
&lt;/h2&gt;

&lt;p&gt;Claude Code can be launched as &lt;code&gt;claude --worktree &amp;lt;name&amp;gt;&lt;/code&gt;, and defining a &lt;strong&gt;WorktreeCreate hook&lt;/strong&gt; lets the hook own the creation of the worktree. The contract is simple:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;stdout must contain only the worktree's directory path&lt;/strong&gt; (all other logs go to stderr)&lt;/li&gt;
&lt;li&gt;non-zero exit blocks the creation&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the hook does &lt;code&gt;git worktree add&lt;/code&gt; plus the full environment build (DB container, env, dependencies, dev server), then &lt;code&gt;claude --worktree fix-login&lt;/code&gt; brings up the &lt;em&gt;environment&lt;/em&gt;, not just the worktree. Three refinements proved necessary in practice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Mirror progress to /dev/tty
&lt;/h3&gt;

&lt;p&gt;Hook stderr is only visible in Claude Code's debug mode. Environment builds take minutes, so silence looks like a hang. When a terminal can be opened, mirror progress to &lt;code&gt;/dev/tty&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# test by actually opening it (-w can pass while opening still fails)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;exec &lt;/span&gt;4&amp;gt; /dev/tty&lt;span class="o"&gt;)&lt;/span&gt; 2&amp;gt;/dev/null&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;exec &lt;/span&gt;3&amp;gt;&amp;amp;2 2&amp;gt; &lt;span class="o"&gt;&amp;gt;(&lt;/span&gt;&lt;span class="nb"&gt;tee&lt;/span&gt; /dev/tty &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&amp;amp;3&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Leftover branches: present options and abort
&lt;/h3&gt;

&lt;p&gt;Teardown keeps branches (for history), so re-creating under the same name hits "the branch already exists." Reusing silently is risky; deleting silently is worse. And &lt;strong&gt;hooks cannot ask questions&lt;/strong&gt;. So the policy is passed via an environment variable, and with none set, the hook prints the options and exits non-zero:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;reuse&lt;/code&gt; … reuse the existing branch (carrying over previous commits)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;new&lt;/code&gt; … create a suffixed new branch (&lt;code&gt;-2&lt;/code&gt;, &lt;code&gt;-3&lt;/code&gt;…) from base&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;delete&lt;/code&gt; … delete and recreate from base (aborts if commits not on base exist; &lt;code&gt;delete-force&lt;/code&gt; overrides)&lt;/li&gt;
&lt;li&gt;unset … show the choices above and abort&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Hooks can't interact, so convert judgment calls into &lt;strong&gt;abort + how to re-run&lt;/strong&gt;" is a pattern that generalizes to other hooks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Self-replicate the tooling into the worktree
&lt;/h3&gt;

&lt;p&gt;A worktree checks out the base branch's content. Until the hooks and skills are merged into base, sessions opened in the worktree get neither auto start/stop nor the skill. So at the end of creation, the hook &lt;strong&gt;copies settings / hooks / skills from the main side into the worktree&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That copy needs mis-commit protection: a worktree-local &lt;code&gt;.claude/.gitignore&lt;/code&gt; listing exactly what was copied. Two subtleties:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;don't use &lt;code&gt;git&lt;/code&gt;'s &lt;code&gt;info/exclude&lt;/code&gt;&lt;/strong&gt; … it's a &lt;strong&gt;shared&lt;/strong&gt; file even across linked worktrees, so it would leak to the main side&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;do not ignore skills&lt;/strong&gt; … gitignored files can be missed by skill discovery scans; ignoring them breaks the skill itself&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Principle 4: an "environment-variable protocol" for entrances that take no flags
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;claude --worktree &amp;lt;name&amp;gt;&lt;/code&gt; accepts no arbitrary flags, and the JSON a hook receives has no argv. Yet real options exist: clone the DB with data or schema-only? which base branch?&lt;/p&gt;

&lt;p&gt;The answer is environment variables. Options live in a &lt;code&gt;WORKTREE_*&lt;/code&gt; namespace, used by the flagless entrances:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;WORKTREE_TITLE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"rework login screen"&lt;/span&gt; &lt;span class="nv"&gt;WORKTREE_DB&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;schema claude &lt;span class="nt"&gt;--worktree&lt;/span&gt; fix-login
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Direct CLI calls can use flags, so the rule became "&lt;strong&gt;everything is settable via env var or flag, and flags win&lt;/strong&gt;." Interpretation is centralized in one function, and a &lt;code&gt;__setup-opts&lt;/code&gt; subcommand prints how the current env vars will be interpreted. Paths with no flags are also hard to debug — that inspection port quietly earns its keep.&lt;/p&gt;

&lt;p&gt;The same trick powers a custom launcher. Project C ships a wrapper that shadows &lt;code&gt;claude&lt;/code&gt; as a shell function, intercepts its own flag, converts it to an env var, and launches plain &lt;code&gt;claude&lt;/code&gt;. SessionStart hooks &lt;strong&gt;inherit env vars set before launch&lt;/strong&gt; (verified empirically), so the hook detects the variable and routes to environment creation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;claude -we 1234
  └ wrapper: intercepts -we → sets WORKTREE_ENV_REF=1234 → launches plain claude
      └ SessionStart hook: detects WORKTREE_ENV_REF
           on startup → runs the CLI's create
           on resume  → shows status + DB connection info
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One operational caution: keep SessionStart's automatic work light (worktree creation + record only), and defer heavy work (dev-server boot, first container build) to after the session opens. Hooks block session startup; a ten-minute build there ruins the experience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Principle 5: "collision-free" is earned by abandoning fixed values
&lt;/h2&gt;

&lt;p&gt;The nastiest problem with concurrent environments is collisions — ports and resource names. This went through three generations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation 1: fixed ports + mutual exclusion.&lt;/strong&gt; Project B's emulator suite has fixed ports, so it runs one environment at a time. SessionStart checks port occupancy with &lt;code&gt;lsof&lt;/code&gt;; if another environment holds it, it doesn't start and injects that fact into context, returning the decision to the human. A fair second-best under the constraint.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation 2: sequential IDs from a shared registry.&lt;/strong&gt; Allocate an ID per environment and offset ports as &lt;code&gt;port = base + ID * 100&lt;/code&gt;. It broke down for two reasons. The registry JSON's read-modify-write has &lt;strong&gt;no lock&lt;/strong&gt; — two simultaneous creates both take ID 1 and everything collides. And fixed ports collide with &lt;strong&gt;unrelated existing processes&lt;/strong&gt; (like the main dev server) no matter how correct the allocation is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Generation 3: content-derived IDs + dynamic allocation.&lt;/strong&gt; The current shape.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;environment ID&lt;/strong&gt; is derived &lt;strong&gt;from content&lt;/strong&gt;: &lt;code&gt;&amp;lt;task key, lowercased&amp;gt;-&amp;lt;short hash of the worktree path&amp;gt;&lt;/code&gt;. It namespaces docker compose project names, containers, volumes, networks. Because the ID is determined by content, allocation races are impossible by construction (and no shared registry is needed)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fixed ports.&lt;/strong&gt; The dev server gets a free port from the OS (&lt;code&gt;socket.bind(("", 0))&lt;/code&gt;); containers let docker assign and are read back afterwards. Infra containers (DB etc.) aren't host-published at all — apps resolve service names inside the docker network; only a GUI-client port is published dynamically&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No fixed subnets&lt;/strong&gt; (docker auto-assigns)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With no fixed values anywhere, any number of environments coexist. The price is &lt;strong&gt;predictability&lt;/strong&gt;: URLs change every run, so completion output always prints them, and Principle 2's SessionStart injection keeps feeding them into the session.&lt;/p&gt;

&lt;h3&gt;
  
  
  Make runtime.json the single source of truth
&lt;/h3&gt;

&lt;p&gt;The dynamically resolved ports, URLs, PIDs, and state are recorded in a per-worktree &lt;code&gt;runtime.json&lt;/code&gt; (gitignored). This file is the environment's &lt;strong&gt;single source of truth&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"env_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"task-1234-cc92ae"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"rework login screen"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"running"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"fe"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;60398&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:60398"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"pid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"backend"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"compose_project"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"task-1234-cc92ae"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"db"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"port"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;55231&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crucially, &lt;strong&gt;teardown is driven by this record&lt;/strong&gt;. &lt;code&gt;remove&lt;/code&gt; reads PIDs, the compose project, and the worktree path from runtime.json and reliably cleans up: kill processes → &lt;code&gt;docker compose down -v&lt;/code&gt; → delete artifacts → &lt;code&gt;git worktree remove&lt;/code&gt;. "Record what you create; delete from the record" eliminates cleanup leaks.&lt;/p&gt;

&lt;h2&gt;
  
  
  Invariants — guaranteed by the script, never to be broken
&lt;/h2&gt;

&lt;p&gt;On top of the design decisions sit invariants shared by all projects. They're also written into the skill definition ("the script guarantees these; do not make changes that break them") to guard against agent edits.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never write to the main environment&lt;/strong&gt; … don't touch normal checkouts, their ports, containers, DBs, or config. Even backend compose definitions stay unmodified — only an external override file swaps values (repositories unmodified)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teardown removes everything — except the branch&lt;/strong&gt; … clean up all dedicated resources including &lt;code&gt;down -v&lt;/code&gt;, but keep the branch so pushed PRs are unaffected&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never copy plaintext secrets into worktrees&lt;/strong&gt; … env files with credentials are excluded from copying&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Destructive operations require confirmation&lt;/strong&gt; … &lt;code&gt;remove&lt;/code&gt; and DB &lt;code&gt;restore&lt;/code&gt; present the target and impact (e.g. uncommitted changes will be lost) before running&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Daily flow, and the quietly useful commands
&lt;/h2&gt;

&lt;p&gt;The daily rhythm looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;create 1234 &lt;span class="nt"&gt;--desc&lt;/span&gt; &lt;span class="s2"&gt;"rework login screen"&lt;/span&gt;   &lt;span class="c"&gt;# build the environment (worktree + deps)&lt;/span&gt;
up 1234 &lt;span class="nt"&gt;--with-backend&lt;/span&gt; &lt;span class="nt"&gt;--clone-db&lt;/span&gt;          &lt;span class="c"&gt;# start (DB cloned from main)&lt;/span&gt;
stop 1234                                  &lt;span class="c"&gt;# pause before going home (nothing deleted)&lt;/span&gt;
start 1234                                 &lt;span class="c"&gt;# resume next day (same DB)&lt;/span&gt;
&lt;span class="nb"&gt;pr &lt;/span&gt;1234                                    &lt;span class="c"&gt;# push + create PR&lt;/span&gt;
remove 1234                                &lt;span class="c"&gt;# full teardown after merge&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Operation forced a set of "boring but effective" commands into existence:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;list&lt;/code&gt;&lt;/strong&gt; … all environments, keeping the creation-time &lt;code&gt;--title&lt;/code&gt; / &lt;code&gt;--desc&lt;/code&gt; visible. Past four parallel tasks, "what was this one again?" genuinely happens&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;doctor&lt;/code&gt;&lt;/strong&gt; … diagnostics: required tools, docker state, &lt;strong&gt;mismatches between the record (runtime.json) and reality&lt;/strong&gt;, orphaned compose-project candidates&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;clean&lt;/code&gt;&lt;/strong&gt; … leftover detection. Report-only by default; &lt;code&gt;--force&lt;/code&gt; after review. A marker file protects environments you keep on purpose&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;snapshot&lt;/code&gt; / &lt;code&gt;restore&lt;/code&gt;&lt;/strong&gt; … DB rollback to any point. Invaluable for migration work&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;logs&lt;/code&gt;&lt;/strong&gt; … aggregates dev-server and container logs. Never &lt;code&gt;tail -f&lt;/code&gt; — it blocks the agent&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;doctor&lt;/code&gt; and &lt;code&gt;clean&lt;/code&gt; are the safety net for the premise that &lt;strong&gt;automated management will eventually drift from reality&lt;/strong&gt;. Machines die before auto-stop runs; humans touch docker directly. Having "reconcile record vs. reality" as a command spares you manual forensics when it drifts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;git worktree&lt;/code&gt; isolates only code. &lt;strong&gt;Environment isolation and lifetime management (DB, ports, processes)&lt;/strong&gt; are built from hooks and a CLI&lt;/li&gt;
&lt;li&gt;Put the real work in a deterministic CLI; keep the skill a translator. However many entrances (launcher / terminal / natural language), the substance is one&lt;/li&gt;
&lt;li&gt;Make session = environment lifetime with SessionStart / SessionEnd; share connection info from second zero via stdout injection&lt;/li&gt;
&lt;li&gt;Hooks can't interact and get no argv. Convert judgments into "abort + how to re-run," and options into an "environment-variable protocol"&lt;/li&gt;
&lt;li&gt;Collision-freedom comes from abandoning fixed values: content-derived IDs + dynamic allocation + runtime.json as the single source of truth&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Parallel development with agents is limited less by "how fast the agent is" than by "how many workspaces you can line up." Per-worktree disposable environments are the infrastructure for the lining-up side. The complementary question — "which repos may this session touch, and how far" — is covered in &lt;a href="https://cilly-yllic.github.io/en/notes/ai-agents/scope-and-permission-guardrails-for-coding-agents/" rel="noopener noreferrer"&gt;Enforce with config, not attention — scope and permission guardrails for coding agents&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>git</category>
      <category>docker</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Syncing what comes after provisioning — Cloudflare DNS, Logto, cert-gated proxy, env bundles</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:00:38 +0000</pubDate>
      <link>https://dev.to/cilly/syncing-what-comes-after-provisioning-cloudflare-dns-logto-cert-gated-proxy-env-bundles-2i90</link>
      <guid>https://dev.to/cilly/syncing-what-comes-after-provisioning-cloudflare-dns-logto-cert-gated-proxy-env-bundles-2i90</guid>
      <description>&lt;p&gt;In &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/config-driven-gcp-firebase-provisioning/" rel="noopener noreferrer"&gt;Provisioning GCP / Firebase environments from a single settings.yml&lt;/a&gt;, I covered getting GCP projects and the Firebase platform generated automatically from a settings file. But that's not the end. Add one environment and a chain of chores appears outside the foundation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add &lt;strong&gt;DNS records&lt;/strong&gt; in Cloudflare pointing at the Firebase custom domain&lt;/li&gt;
&lt;li&gt;Once delivery is stable, turn the &lt;strong&gt;proxy on&lt;/strong&gt; to get CDN / WAF&lt;/li&gt;
&lt;li&gt;Set up &lt;strong&gt;Logto&lt;/strong&gt; applications / API resources / roles for the operator console&lt;/li&gt;
&lt;li&gt;Collect each app's &lt;strong&gt;env values&lt;/strong&gt;, encrypt them, and make them referenceable at deploy time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These live in a different layer from provisioning, but left alone they regress into "manual work per environment." So I built Platform Sync — a mechanism that keeps them &lt;strong&gt;following the same source of truth&lt;/strong&gt; as the foundation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The big picture
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1gibws9nxvew8agish7.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fg1gibws9nxvew8agish7.png" alt="Architecture diagram of the Platform Sync pipeline" width="799" height="488"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Config (Git) as SoT, chaining Cloudflare DNS → Logto → proxy → env bundle through a series of dispatches.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There are two SoT files. &lt;code&gt;terraform.yml&lt;/code&gt; holds service / environments / hosting (i.e. where domains and DNS come from); &lt;code&gt;platform.yml&lt;/code&gt; holds Logto's auth rules and the tenant ⇄ env-group mapping. Platform Sync itself fires manually via &lt;code&gt;workflow_dispatch&lt;/code&gt;, defaulting to &lt;code&gt;plan&lt;/code&gt; (diff display only). To prevent accidents, the writing mode &lt;code&gt;apply&lt;/code&gt; only runs when explicitly selected.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# platform.yml (excerpt, abstracted)&lt;/span&gt;
&lt;span class="na"&gt;tenants&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-service-non-production&lt;/span&gt;
    &lt;span class="na"&gt;env_groups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;stg&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;      &lt;span class="c1"&gt;# the group of envs this tenant is responsible for&lt;/span&gt;
    &lt;span class="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;admin&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;write&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;developer&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;scopes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;secrets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;read&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Processing runs &lt;code&gt;Cloudflare DNS → Logto&lt;/code&gt;, in that order. Logto's &lt;code&gt;redirectUri&lt;/code&gt; depends on the domain (= DNS / hosting), so &lt;code&gt;apply&lt;/code&gt; runs Cloudflare first. And &lt;strong&gt;only when &lt;code&gt;apply&lt;/code&gt; succeeds&lt;/strong&gt; are the two follow-up workflows (Cloudflare Proxy / Env Bundle) &lt;code&gt;dispatch&lt;/code&gt;ed. All authentication is keyless: each environment's read-only service account is impersonated through Workload Identity Federation (OIDC). No long-lived credentials anywhere.&lt;/p&gt;

&lt;h2&gt;
  
  
  The default is "never delete"
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;plan&lt;/code&gt; and &lt;code&gt;apply&lt;/code&gt; share the same diff computation, with the mode toggling display-only versus real writes. The decision that pays off here: &lt;strong&gt;per-environment sync emits no deletions, ever&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Cloudflare's desired state is derived from the DNS updates Firebase requests. But those requests empty out once the domain goes ACTIVE — they're satisfied. So naively deleting "records that exist but aren't in desired" risks &lt;strong&gt;taking out records that are serving traffic&lt;/strong&gt;. Per-env sync therefore only creates what's missing; deletion is split into a separate &lt;code&gt;reconcile&lt;/code&gt; job. Reconcile matches against the env naming convention (&lt;code&gt;&amp;lt;tier&amp;gt;-&amp;lt;number&amp;gt;&lt;/code&gt;) and tier constraints, and only records not belonging to defined envs or &lt;code&gt;retained_envs&lt;/code&gt; become deletion candidates — the line that avoids false positives on unrelated records like &lt;code&gt;www&lt;/code&gt; or &lt;code&gt;api&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deletion is always dangerous, so it only activates with an explicit &lt;code&gt;allow_delete&lt;/code&gt; flag; &lt;code&gt;plan&lt;/code&gt; stops at previewing "what would be deleted."&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard part: don't turn the proxy on until the cert exists
&lt;/h2&gt;

&lt;p&gt;The timing of the proxy switch is what this pipeline sweats over most.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iv0yk1y31k0z30h49an.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0iv0yk1y31k0z30h49an.png" alt="Flow of waiting for the certificate to go ACTIVE before enabling the proxy" width="800" height="407"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Create DNS-only → let Firebase issue the cert → wait for ACTIVE → proxy only the serving records. The order carries meaning.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;DNS records are &lt;strong&gt;always created with &lt;code&gt;proxied: false&lt;/code&gt;&lt;/strong&gt; (DNS-only, grey cloud). In that state, Firebase can reach the origin directly for domain-ownership verification and certificate issuance. Turn the proxy on too early and the verification and ACME challenge traffic hides behind Cloudflare's proxy (different cert, different IPs) — &lt;strong&gt;the certificate can't be issued and delivery breaks&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So the Cloudflare Proxy workflow polls the Firebase custom domain's host / cert status at 15-second intervals. Once it reaches &lt;code&gt;HOST_ACTIVE &amp;amp;&amp;amp; CERT_ACTIVE&lt;/code&gt; (treating &lt;code&gt;CERT_PROPAGATING&lt;/code&gt; during distribution as "issued = reachable"), it flips &lt;strong&gt;only the serving records that point at the hostname itself&lt;/strong&gt; to &lt;code&gt;proxied: true&lt;/code&gt;. Ownership TXT records and ACME-challenge CNAMEs stay DNS-only.&lt;/p&gt;

&lt;p&gt;Certificate issuance takes time. That's why this stage isn't embedded in the main workflow but &lt;strong&gt;split into an independent workflow that can be re-run later&lt;/strong&gt;. Timing out while pending isn't a failure; run it again and it resumes waiting for ACTIVE. Already-proxied records are skipped, so it's safe to run any number of times.&lt;/p&gt;
&lt;h2&gt;
  
  
  Logto: auth config that depends on domains
&lt;/h2&gt;

&lt;p&gt;The Logto side syncs per tenant. It obtains &lt;code&gt;client_credentials&lt;/code&gt; for the Management API using an M2M app's credentials and reuses the token. Three kinds of things are synced:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;API Resources&lt;/strong&gt; (+ scopes / permissions)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Roles&lt;/strong&gt; (+ scope assignments)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Applications&lt;/strong&gt; (the SPA for the operator console)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Writes have ordering constraints: assigning scopes to a role requires the resource-side scope ids, so it proceeds &lt;strong&gt;API Resources → Roles → Applications&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;An application's &lt;code&gt;redirectUri&lt;/code&gt; is assembled from the environment's domain (&lt;code&gt;https://&amp;lt;domain&amp;gt;/callback&lt;/code&gt;, etc.). The point is that target domains are &lt;strong&gt;derived from &lt;code&gt;terraform.yml&lt;/code&gt;'s hosting definitions&lt;/strong&gt;, not listed in &lt;code&gt;platform.yml&lt;/code&gt;. This is also why &lt;code&gt;apply&lt;/code&gt; runs Cloudflare first — without the domain settled, you can't build the correct &lt;code&gt;redirectUri&lt;/code&gt;. Deletion is conservative here too: only applications it manages — names starting with &lt;code&gt;&amp;lt;service&amp;gt;-&lt;/code&gt; and ending with &lt;code&gt;-web-system-console&lt;/code&gt; — can ever be treated as orphans.&lt;/p&gt;
&lt;h2&gt;
  
  
  Env bundles and GPG: keep it off argv
&lt;/h2&gt;

&lt;p&gt;Last is how env values are bundled. Per environment, a value source (JSON including the Firebase web app config and Logto app id) is generated, tarred, and encrypted with &lt;strong&gt;GPG symmetric encryption&lt;/strong&gt; into a &lt;code&gt;.tar.gpg&lt;/code&gt;. The encrypted bundle is committed; the decrypted plaintext JSON is gitignored. Only "the key itself" lives in CI secrets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# The passphrase arrives via env var and flows only into fd 3 via process substitution&lt;/span&gt;
&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; pipefail
&lt;span class="nb"&gt;tar &lt;/span&gt;cf - &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$src&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | gpg &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="nt"&gt;--batch&lt;/span&gt; &lt;span class="nt"&gt;--yes&lt;/span&gt; &lt;span class="nt"&gt;--pinentry-mode&lt;/span&gt; loopback &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--passphrase-fd&lt;/span&gt; 3 &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$out&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; 3&amp;lt; &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; %s &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PASSPHRASE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unglamorous but important: how the passphrase is passed. Put it on the command line and it's visible in &lt;code&gt;ps&lt;/code&gt;, so instead it's &lt;strong&gt;fed through process substitution into fd 3 and read with &lt;code&gt;--passphrase-fd 3&lt;/code&gt;&lt;/strong&gt;. &lt;code&gt;printf&lt;/code&gt; is a bash builtin, so it doesn't even become a separate process. A path where the passphrase appears neither in argv nor in any other process.&lt;/p&gt;

&lt;p&gt;The other trick is the diff. GPG encryption &lt;strong&gt;produces different bytes every run&lt;/strong&gt;, so diffing bundles directly yields changes even when the content is identical — mass-producing meaningless PRs. So the existing bundle is &lt;strong&gt;decrypted once and the plaintexts compared&lt;/strong&gt;; re-encryption happens only when values actually changed (or no bundle exists). PRs force-push to a fixed branch: update the open PR if one exists, create one if not — never duplicating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Developers hold no secrets
&lt;/h2&gt;

&lt;p&gt;The heart of this setup: &lt;strong&gt;a developer can reach deployment without ever holding a dev environment's env values or the decryption key&lt;/strong&gt;. Generating the value sources, encrypting with GPG, decrypting at deploy — all of it completes inside CI. The passphrase is held only by CI as a CI secret; the only thing in Git is the encrypted &lt;code&gt;.tar.gpg&lt;/code&gt;. The plaintext value source appears only on CI runners during generation and is gitignored, so it's never committed.&lt;/p&gt;

&lt;p&gt;In other words, what developers touch day-to-day is the config (&lt;code&gt;terraform.yml&lt;/code&gt; / &lt;code&gt;platform.yml&lt;/code&gt;) and the finished encrypted bundles. "No dev secrets on anyone's laptop" is guaranteed &lt;strong&gt;by the mechanism&lt;/strong&gt;, not by operating rules or review diligence. Since no distribution channel for secrets exists, the accidents themselves — leaks during distribution, stale &lt;code&gt;.env&lt;/code&gt; files quietly diverging across machines — can't happen. It's the same idea as going keyless (WIF) for cloud credentials, extended to application env values.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions that pay off
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keyless doubles as an existence check.&lt;/strong&gt; When cleaning up bundles for retired environments, "does that GCP project still exist?" is answered by attempting to impersonate the read-only SA with &lt;code&gt;access_token&lt;/code&gt; and checking success. If the project / SA is gone, impersonation fails — that failure is the "doesn't exist" signal. (One caveat: without &lt;code&gt;token_format&lt;/code&gt;, the auth step merely writes config and always succeeds, so it decides nothing.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Heavy stages are split into independent workflows.&lt;/strong&gt; The certificate wait is separated from the main body, and only environments with DNS changes are handed to the follow-ups. Zero changes means the proxy workflow isn't even started.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I gave up
&lt;/h2&gt;

&lt;p&gt;The cost is the growing chain of workflow &lt;code&gt;dispatch&lt;/code&gt;es. The &lt;code&gt;apply&lt;/code&gt; → proxy / env-bundle branching trades clarity for the chore of tracking "where did it stop." With Cloudflare, Logto, Firebase, and GitHub Actions all involved, failure triage is harder than in a single system.&lt;/p&gt;

&lt;p&gt;Even so, collapsing the cost of one more environment into "add a few lines of config and run &lt;code&gt;apply&lt;/code&gt;" is worth a lot. &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/config-driven-gcp-firebase-provisioning/" rel="noopener noreferrer"&gt;Last time&lt;/a&gt;, the foundation was generated from config. This time, everything outside it — DNS, auth, delivery, env — follows the same config. Push infrastructure all the way into "something you write and generate," and the operational concern shifts from individual environments to the machinery that generates and syncs them.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/platform-sync-dns-logto-proxy-env/" rel="noopener noreferrer"&gt;cilly-yllic.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cloudflare</category>
      <category>githubactions</category>
      <category>devops</category>
      <category>security</category>
    </item>
    <item>
      <title>Runtime and delivery of a multi-service Firebase environment</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Thu, 23 Jul 2026 12:00:32 +0000</pubDate>
      <link>https://dev.to/cilly/runtime-and-delivery-of-a-multi-service-firebase-environment-1mnj</link>
      <guid>https://dev.to/cilly/runtime-and-delivery-of-a-multi-service-firebase-environment-1mnj</guid>
      <description>&lt;p&gt;I designed a Firebase / GCP environment where multiple services live together in a single GCP project. Leaving out anything product-specific, this note presents the architecture itself through two cross-sections: "how it runs" (runtime) and "how it ships" (delivery).&lt;/p&gt;

&lt;h2&gt;
  
  
  Runtime: separate writes from reads
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs94gu149xv2wmgmhj1mf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fs94gu149xv2wmgmhj1mf.png" alt="Runtime architecture and data flow" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;Writes go to SQL (the SoT); reads come from Firestore (a rebuildable cache). The backend updates both at once, and reconciliation guarantees consistency.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;At the center of this environment is a design that &lt;strong&gt;separates writes and reads into different datastores&lt;/strong&gt;. Writes are received by the backend (Next.js / Cloud Run), which updates both the SoT (Cloud SQL via Firebase Data Connect) and Firestore in the same operation. Cloud SQL is the source of truth; Firestore is a &lt;strong&gt;read-only cache&lt;/strong&gt; that can be rebuilt from the SoT. Clients stream Firestore via &lt;code&gt;onSnapshot&lt;/code&gt;, so their own writes reflect immediately. Divergence from the double write is repaired by a reconciliation job — enqueued before the DB updates — that treats the SoT as authoritative.&lt;/p&gt;

&lt;p&gt;The benefits and costs of this "Firestore as cache, not SoT" decision are explored in a separate note, so I won't go deeper here:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/firestore-as-public-cache/" rel="noopener noreferrer"&gt;Treating Firestore as a public cache&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;What this note is really about is the two remaining structures that make this read/write separation work on &lt;strong&gt;a single GCP project shared by multiple services&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-layer DB: Core and Service
&lt;/h3&gt;

&lt;p&gt;The database is &lt;strong&gt;two-layered&lt;/strong&gt;: a &lt;strong&gt;Core DB&lt;/strong&gt; holding cross-cutting foundation entities — identity, organizations, membership — and a &lt;strong&gt;Service DB&lt;/strong&gt; holding each service's own domain entities. This repeats in the same shape in both Cloud SQL and Firestore.&lt;/p&gt;

&lt;p&gt;The Service DB only references Core's identifiers; it never duplicates shared data. Cramming multiple services into one project tends to scatter identity everywhere, but this boundary preserves the state where "from any service's viewpoint, users and organizations come from Core, and only Core." Adding a service means adding only a Service DB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two authentication tracks
&lt;/h3&gt;

&lt;p&gt;Authentication isn't singular either. &lt;strong&gt;End-user apps authenticate with Firebase Authentication&lt;/strong&gt;; &lt;strong&gt;the operator console authenticates with an external OIDC IdP&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The latter can't use &lt;code&gt;onCall&lt;/code&gt;, which assumes Firebase Auth — so Cloud Functions are exposed as &lt;code&gt;onRequest&lt;/code&gt;, and the function itself does JWT verification against JWKS plus scope-based authorization. Two authentication domains coexist on physically separate paths. When you want end users and operators to have different roots of trust, splitting the path entirely turned out to be a much clearer boundary than straining Firebase Auth with custom claims.&lt;/p&gt;

&lt;h2&gt;
  
  
  Delivery: fan out from a monorepo to each environment
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipufhla2stveedzw6yro.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipufhla2stveedzw6yro.png" alt="Multi-target deployment architecture from the monorepo" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;From one Nx monorepo, a keyless CI fans out to per-environment GCP projects.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Behind how it runs is &lt;strong&gt;a CI that fans out from a single Nx monorepo to multiple deploy targets&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The monorepo holds, per service, the API (Cloud Functions) / Web (SSR or SPA) / security rules / Data Connect schema. Nx builds only the affected apps.&lt;/li&gt;
&lt;li&gt;CI (GitHub Actions) &lt;strong&gt;holds no keys&lt;/strong&gt;. It operates GCP through Workload Identity Federation (OIDC), impersonating the deploy service account. No SA key files sit in the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;firebase deploy&lt;/code&gt; &lt;strong&gt;fans out&lt;/strong&gt; to each target in the per-environment GCP project (Cloud Functions / App Hosting / Hosting / Firestore rules &amp;amp; indexes / Storage rules / Data Connect schema). Environments switch through templated config.&lt;/li&gt;
&lt;li&gt;Projects, IAM, WIF, API enablement, and Firebase Auth are &lt;strong&gt;provisioned by Terraform&lt;/strong&gt; before any deploy. The "create the environment" layer and the "ship the code" layer are kept separate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "create the environment" side (provisioning GCP / Firebase with Terraform + GitHub Actions) is covered in a separate note:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/config-driven-gcp-firebase-provisioning/" rel="noopener noreferrer"&gt;Provisioning GCP / Firebase environments from a single settings.yml&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions that pay off
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Read/write separation.&lt;/strong&gt; Consistent related updates and relational constraints go to SQL; realtime push goes to Firestore. Once you shrug Firestore off as "a cache that can break and be rebuilt," you gain full freedom in modeling the read side.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One project, shared + two-layer DB.&lt;/strong&gt; Putting multiple services in one GCP project lets shared identity and runtime (Functions codebases) coexist naturally. The Core / Service two-layer structure is the boundary that keeps that cohabitation from collapsing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keyless.&lt;/strong&gt; Issuing no SA key files removes long-lived secrets from the pipeline. CI and TFC alike borrow permissions through OIDC only for the moment they need them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;I said "presents," but what these two cross-sections really show is a map of &lt;strong&gt;where responsibilities live&lt;/strong&gt;. Writes in SQL, reads in Firestore, auth split between users and operators, provisioning in Terraform, deployment in the firebase CLI. Decide the boundaries first, and as services multiply, you keep adding onto the same map.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/multi-service-firebase-runtime-and-delivery/" rel="noopener noreferrer"&gt;cilly-yllic.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>gcp</category>
      <category>architecture</category>
      <category>monorepo</category>
    </item>
    <item>
      <title>Treating Firestore as a public cache</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:03:01 +0000</pubDate>
      <link>https://dev.to/cilly/treating-firestore-as-a-public-cache-3mkm</link>
      <guid>https://dev.to/cilly/treating-firestore-as-a-public-cache-3mkm</guid>
      <description>&lt;p&gt;Using Firestore as "the app's primary database" is easy at first. Writes and reads complete in one place, and &lt;code&gt;onSnapshot&lt;/code&gt; gives you realtime push for free. But as a service grows, keeping Firestore as the SoT (source of truth) exposes some fatal constraints.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where SoT-Firestore hurts
&lt;/h2&gt;

&lt;p&gt;Weak transaction boundaries, missing complex queries, the cost structure, the difficulty of migrating away. The harshest one: you cannot narrow related updates with a &lt;code&gt;WHERE&lt;/code&gt;. For the use case "update a set of documents matching a condition, consistently, in one go," Firestore is structurally weak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Redefining it as a public cache
&lt;/h2&gt;

&lt;p&gt;In one project, I made Cloud SQL the SoT and treated Firestore as a "read-optimized projection." Writes go through the backend (Next.js / Cloud Run), and &lt;strong&gt;the SoT (Cloud SQL via Data Connect) and Firestore are both updated within the same write path&lt;/strong&gt;. Separately, a Cloud Run reconciliation job runs and &lt;strong&gt;checks and repairs consistency&lt;/strong&gt; against the SoT as authoritative. This reconciliation job is enqueued at the start of the request, &lt;strong&gt;before&lt;/strong&gt; the DB updates. Because it's queued first, even if the write dies halfway, the consistency check always runs afterward and repairs the state. From the client's perspective, Firestore is a &lt;strong&gt;rebuildable cache&lt;/strong&gt; — in the worst case it can be rebuilt from the SoT.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Writes go through the backend. The backend updates both the SoT&lt;/span&gt;
&lt;span class="c1"&gt;// (Data Connect → Cloud SQL) and Firestore. Clients never write Firestore directly.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;backend&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;updateEntity&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;title&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="c1"&gt;// Changes are pushed back in realtime via Firestore's onSnapshot&lt;/span&gt;
&lt;span class="nx"&gt;unsubscribe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;onSnapshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;entityRef&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;snap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;data&lt;/span&gt;&lt;span class="p"&gt;()));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Benefits and costs
&lt;/h2&gt;

&lt;p&gt;The benefits are clear. The SoT side (SQL) brings transactional consistency and complex queries; the read side (Firestore) brings freedom in data modeling and realtime push; and consistent syncing with external systems (OpenSearch / BigQuery, etc.) coexists naturally. Since the backend updates Firestore at write time, the frontend gets &lt;strong&gt;immediate reflection&lt;/strong&gt; through its &lt;code&gt;onSnapshot&lt;/code&gt; stream. No optimistic-update tricks like "hold the value I just wrote in the UI for a while."&lt;/p&gt;

&lt;p&gt;The cost is not latency — it moves to &lt;strong&gt;double writes and consistency&lt;/strong&gt;. The backend writes two places, SoT and Firestore, so if one fails they can diverge (this is not a single distributed transaction). What closes that gap is the Cloud Run reconciliation, verifying and repairing Firestore with the SoT as truth. You can only shrug Firestore off as "a cache that can break and be rebuilt" because that consistency check stands behind it. The operational cost concentrates in this double-write path and the reconciliation route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which to choose
&lt;/h2&gt;

&lt;p&gt;The choice between "Firestore as SoT" and "Firestore as cache" comes down to the expected lifespan of the service. A short-lived prototype: the former. Something you intend to grow for years: the latter. And if you're in between — the cost of switching over later is far higher than the cost of treating it as a cache from day one.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/firestore-as-public-cache/" rel="noopener noreferrer"&gt;cilly-yllic.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>firebase</category>
      <category>database</category>
      <category>architecture</category>
      <category>gcp</category>
    </item>
    <item>
      <title>Provisioning GCP / Firebase environments from a single settings.yml</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Thu, 23 Jul 2026 00:02:58 +0000</pubDate>
      <link>https://dev.to/cilly/provisioning-gcp-firebase-environments-from-a-single-settingsyml-5a9e</link>
      <guid>https://dev.to/cilly/provisioning-gcp-firebase-environments-from-a-single-settingsyml-5a9e</guid>
      <description>&lt;p&gt;Every time you add a service on GCP and Firebase, the same manual work repeats. Create the project, attach billing, enable APIs, set up the Terraform service account and Workload Identity, configure Firestore / Auth / Storage / App Hosting. Multiply all of that by the number of environments (dev / stg / prd). No matter how carefully you write the runbook, as long as human hands are involved, environments drift.&lt;/p&gt;

&lt;p&gt;So I built a platform where &lt;strong&gt;dropping a single &lt;code&gt;settings.yml&lt;/code&gt; per service provisions everything automatically&lt;/strong&gt;. The configuration is the source of truth; the infrastructure is nothing more than its projection.&lt;/p&gt;

&lt;h2&gt;
  
  
  What gets built
&lt;/h2&gt;

&lt;p&gt;Let's start with the "generated" side — the overall picture.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxyp20vknl00mm1d0l52.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmxyp20vknl00mm1d0l52.png" alt="Architecture diagram of the multi-environment GCP / Firebase platform" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;A shared bootstrap project lends permissions, keyless, to per-environment service projects.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The structure has three layers.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Identity &amp;amp; Trust&lt;/strong&gt; — Terraform Cloud and GitHub Actions operate GCP through Workload Identity Federation (OIDC). No service account key files are ever issued.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared bootstrap project&lt;/strong&gt; — the "platform for the platform": the WIF pool / provider, the Terraform service account, the Cloud Run router described below, and Secret Manager. Created once per organization.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-environment service projects&lt;/strong&gt; — independent GCP projects per environment, like &lt;code&gt;my-service-dev-001&lt;/code&gt;. Inside each, Firebase resources (Auth / Firestore / Storage / App Hosting / Data Connect / Functions …) line up according to feature flags.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Holding no keys is what makes this work. Each environment's Terraform SA is &lt;strong&gt;impersonated&lt;/strong&gt; from the bootstrap SA, and that permission only fires through an OIDC token exchange. There is no long-lived credential anywhere that could leak.&lt;/p&gt;
&lt;h2&gt;
  
  
  How it gets generated
&lt;/h2&gt;

&lt;p&gt;Now the "generating" side. From merging &lt;code&gt;settings.yml&lt;/code&gt; to applying against GCP, everything is chained without polling.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsg08clizjymotrngef6f.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsg08clizjymotrngef6f.png" alt="Architecture diagram of the infrastructure generation pipeline" width="800" height="533"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One settings file flows in a straight line: GitHub Actions → Terraform Cloud → GCP.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The flow has two stages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# terraform/settings.yml in the service repo (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;my-service&lt;/span&gt;
&lt;span class="na"&gt;environments&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;dev-001&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;tier&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;dev&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;firebase_platform&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;firebase&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;firestore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
      &lt;span class="na"&gt;app_hosting&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;backend_id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;web&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;location&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;asia-northeast1&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;notifications&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;https://hooks.slack.com/services/...&lt;/span&gt;  &lt;span class="c1"&gt;# notify apply results&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Action A (project-bootstrap)&lt;/strong&gt; reads &lt;code&gt;settings.yml&lt;/code&gt;, filters target environments by &lt;code&gt;status&lt;/code&gt; / &lt;code&gt;labels&lt;/code&gt;, and creates GCP projects / SAs / WIF &lt;strong&gt;in one batched run&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The run's completion notification (HMAC-signed) is received by a &lt;strong&gt;Cloud Run router&lt;/strong&gt;, which verifies it and fires GitHub's &lt;code&gt;repository_dispatch&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;That triggers &lt;strong&gt;Action B (firebase-platform)&lt;/strong&gt;, which stands up a workspace per environment and applies the Firebase resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Instead of keeping a polling orchestrator running, I chained webhooks: Terraform Cloud notification → Cloud Run → GitHub. Only TFC knows the state, so the result notification to Slack is also sent by TFC — the one party that knows whether the apply succeeded.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions that pay off
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Feature flags decide everything down to APIs and IAM.&lt;/strong&gt; Write &lt;code&gt;firestore: true&lt;/code&gt; in &lt;code&gt;settings.yml&lt;/code&gt; and the corresponding API enablement, resource creation, and role grants to the CI SA all cascade from it. Leave it unset and nothing is created (zero side effects). Users never need to memorize which GCP APIs a feature requires.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Configuration ownership stays with the service team.&lt;/strong&gt; Which features to use is the application's concern, so &lt;code&gt;settings.yml&lt;/code&gt; lives in the service repo and the platform side only reads it. The platform never becomes a central registry hoarding every service's configuration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deletion has a separate safety net.&lt;/strong&gt; Removing an environment from the settings normally destroys it, but if its name is listed in &lt;code&gt;retained_envs&lt;/code&gt;, it is only removed from state and the GCP resources stay. A config mistake cannot take production down with it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I gave up
&lt;/h2&gt;

&lt;p&gt;There are costs. The webhook chain adds one more operational component — the Cloud Run router. You have to think about HMAC secret rotation and a retry path for dropped notifications. Since it spans Terraform Cloud, GitHub Actions, and GCP, failure triage is harder than in a single repository.&lt;/p&gt;

&lt;p&gt;Even so, the value of collapsing the cost of adding an environment to "add a few lines to &lt;code&gt;settings.yml&lt;/code&gt; and run the Action" is significant. Manual drift disappears structurally, and anyone gets the same environment. When you push infrastructure all the way into "something you write and generate," the operational concern shifts from individual environments to the generating machinery itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's published
&lt;/h2&gt;

&lt;p&gt;The platform is open source — a monorepo of Terraform modules and GitHub Actions, free for anyone to use.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository — &lt;a href="https://github.com/cilly-yllic/terraform-google-platform" rel="noopener noreferrer"&gt;github.com/cilly-yllic/terraform-google-platform&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Terraform Registry — &lt;a href="https://registry.terraform.io/modules/cilly-yllic/platform/google/latest" rel="noopener noreferrer"&gt;registry.terraform.io/modules/cilly-yllic/platform/google&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/config-driven-gcp-firebase-provisioning/" rel="noopener noreferrer"&gt;cilly-yllic.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>gcp</category>
      <category>firebase</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Enforce with config, not attention — scope and permission guardrails for coding agents</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:17:24 +0000</pubDate>
      <link>https://dev.to/cilly/enforce-with-config-not-attention-scope-and-permission-guardrails-for-coding-agents-26al</link>
      <guid>https://dev.to/cilly/enforce-with-config-not-attention-scope-and-permission-guardrails-for-coding-agents-26al</guid>
      <description>&lt;p&gt;When you put a coding agent (like Claude Code) to work, where it really pays off is &lt;strong&gt;work that spans multiple repositories&lt;/strong&gt;. Writing into one repository &lt;strong&gt;while referencing&lt;/strong&gt; another's implementation. Fixing a shared library while verifying behavior in the repository that consumes it. Updating a generated-artifacts repository while reading the config repository. The agent's speed shines in this kind of "straddling" work far more than in single-feature fixes.&lt;/p&gt;

&lt;p&gt;But cross-repository work widens the blast radius just as much. Accidentally editing a repository you only meant to reference. Pushing straight to another repository's &lt;code&gt;main&lt;/code&gt;. Touching files unrelated to the goal. The more repositories are involved, the blurrier the boundary of "which ones may I touch right now, and how far am I allowed to go" becomes — and the agent re-reads that boundary from scratch every time, so there is structurally nothing equivalent to human attentiveness. That's why "asking nicely in the prompt to be careful" can't fundamentally prevent accidents.&lt;/p&gt;

&lt;p&gt;So I built a mechanism that &lt;strong&gt;enforces guardrails through config files instead of attention&lt;/strong&gt;. The idea is simple: before starting work, declare "the repositories that may be straddled" and "the permission for each," and make everything else technically impossible. The span of the cross-repository work itself gets frozen as configuration up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  A session = a declaration of scope + permissions
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj1sy3wrq77hgdqtwm2j1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fj1sy3wrq77hgdqtwm2j1.png" alt="Architecture diagram: a session declares scope and permissions, handling multiple repositories with distinct read / write-local / write-push permissions" width="800" height="495"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;The parent declares scope and permissions and spawns the session. Each straddled repository is handled per its permission — a "read-only repository" structurally cannot be modified.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I call the smallest unit a "session". One session = one unit of work, and at launch two things are fixed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Scope&lt;/strong&gt; … which repositories / directories may be touched&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Permissions&lt;/strong&gt; … per target, how far is allowed&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Permissions have three levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;read&lt;/code&gt; … reference only&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;write-local&lt;/code&gt; … edit + local commits. &lt;strong&gt;No push&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;write-push&lt;/code&gt; … push allowed (including PR creation)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point is that permissions are held &lt;strong&gt;per target&lt;/strong&gt;. In cross-repository work, repositories of differing trust naturally mix within one session. "Reference the config repo read-only, write-push to the artifacts repo." "Fix the shared library write-local but don't push; use the consumer repo read-only for verification." A session handles combinations like these as one unit of work — so it carries a single declaration (manifest) listing target-level pairs, and scope, permissions, and identity checks all concentrate there.&lt;/p&gt;

&lt;p&gt;In fact, this very article was written in such a cross-repository session: referencing the repository the mechanism comes from as &lt;strong&gt;read&lt;/strong&gt;, while writing the text into the article repository as &lt;strong&gt;write-push&lt;/strong&gt;. The guarantee that the read side can never be modified is enforced by config, so there's no fear of breaking the source being referenced.&lt;/p&gt;
&lt;h2&gt;
  
  
  Build scope as "what isn't opened doesn't exist"
&lt;/h2&gt;

&lt;p&gt;Scope is an allowlist. The list of directories the agent can access enumerates &lt;strong&gt;only the allowed targets&lt;/strong&gt;. Repositories not listed cannot even be referenced. I deliberately avoided symlink tricks so that scope is complete in the config file alone.&lt;/p&gt;

&lt;p&gt;The direction matters: you &lt;strong&gt;add&lt;/strong&gt; what can be touched, never subtract from everything. Because the default is closed, any target you forget to configure fails safe (= invisible).&lt;/p&gt;
&lt;h2&gt;
  
  
  Hard part 1: keep the agent away from its own permission definitions
&lt;/h2&gt;

&lt;p&gt;This is the single most effective design decision. &lt;strong&gt;From inside a session, the files defining scope and permissions themselves cannot be modified.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The logic goes like this. When you give an agent read permission, the config file describing "what counts as read" is also just a file. If the agent could edit it, it could notice "removing this deny lets me write" — room to loosen its own guardrails. An entity holding permissions must not be able to rewrite its own permission definition. So permission changes can only happen &lt;strong&gt;one level up&lt;/strong&gt; (the parent that spawns sessions).&lt;/p&gt;

&lt;p&gt;There's an implementation trap here, though. What you want to protect is "the permission and scope definition files themselves." If you conclude "just ban &lt;code&gt;Edit&lt;/code&gt; / &lt;code&gt;Write&lt;/code&gt; entirely," you fail: the agent can no longer write even its own &lt;strong&gt;work log&lt;/strong&gt;. What's needed is not a per-tool ban but a &lt;strong&gt;per-path ban&lt;/strong&gt;. Deny exactly the definition files by name, and keep the session's working area writable.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json-doc"&gt;&lt;code&gt;&lt;span class="c1"&gt;// What we protect is the session's own permission/scope definitions. Deny exactly those by name.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"deny"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"Write(.claude/**)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="c1"&gt;// permission definitions (settings)&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="s2"&gt;"Write(/CLAUDE.md)"&lt;/span&gt;&lt;span class="w"&gt;    &lt;/span&gt;&lt;span class="c1"&gt;// scope declaration&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="c1"&gt;// Working areas like session logs stay writable&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Conversely, &lt;code&gt;read&lt;/code&gt;-permission repositories get banned &lt;strong&gt;wholesale&lt;/strong&gt; — since they're read-only, excluding the entire tree from &lt;code&gt;Write&lt;/code&gt; / &lt;code&gt;Edit&lt;/code&gt; is the straightforward, safe move. The problem appeared where "ban wholesale" collided with "keep my own logs writable."&lt;/p&gt;

&lt;p&gt;I actually messed this up once. The session's own working directory was &lt;strong&gt;nested under&lt;/strong&gt; a read-target repository. Banning the read target's whole tree took the session's logs down with it — the agent could no longer write its own log.&lt;/p&gt;

&lt;p&gt;What fixed it was not narrowing the deny but &lt;strong&gt;physically separating the working area from the scoped targets&lt;/strong&gt;. Move the session's working directory (logs, scratch notes) &lt;strong&gt;outside&lt;/strong&gt; the protected tree entirely. Then read targets can be banned wholesale with confidence, and the session writes its logs freely. Not shaving down the protected area to make things fit — &lt;strong&gt;splitting the ground so that what you protect and where you write never overlap&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hard part 2: double-lock push
&lt;/h2&gt;

&lt;p&gt;Push is the scariest. Pushing to a &lt;code&gt;read&lt;/code&gt; repository; pushing straight to &lt;code&gt;main&lt;/code&gt;. Both are hard to undo.&lt;/p&gt;

&lt;p&gt;Naively, narrowing the &lt;strong&gt;allowlist&lt;/strong&gt; of push commands stops it. That works well against the agent's &lt;strong&gt;careless accidents&lt;/strong&gt;. But as hardness goes, it's insufficient: an allowlist only checks "is this command shaped like &lt;code&gt;git push&lt;/code&gt;", so it can't judge variants that switch the working directory like &lt;code&gt;git -C &amp;lt;other-repo&amp;gt; push&lt;/code&gt;, nor which repository or branch the push is aimed at. Matching on command shape cannot protect the destination's permission.&lt;/p&gt;

&lt;p&gt;So I made it two layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Narrow "what can push" with the allowlist&lt;/strong&gt; … only sessions holding &lt;code&gt;write-push&lt;/code&gt; get a push command at all. Sessions with only &lt;code&gt;read&lt;/code&gt; or &lt;code&gt;write-local&lt;/code&gt; are never given one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A pre-execution hook&lt;/strong&gt; … inspects the command string, derives the push destination's repository path, looks up its permission, and decides.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hard layer is the second. It looks at the command string about to run, works out the push destination from &lt;code&gt;git -C &amp;lt;path&amp;gt;&lt;/code&gt; or the working directory, and checks it against the manifest (path → permission). Pushes to &lt;code&gt;read&lt;/code&gt; / &lt;code&gt;write-local&lt;/code&gt; paths are all blocked; even on &lt;code&gt;write-push&lt;/code&gt; paths, direct pushes to &lt;code&gt;main&lt;/code&gt; / &lt;code&gt;master&lt;/code&gt; are blocked. If the destination can't be determined, it fails safe and rejects, prompting "specify the target explicitly with &lt;code&gt;git -C &amp;lt;absolute path&amp;gt;&lt;/code&gt;". The hook physically enforces the per-destination permission that command-shape allowlists can't reach.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make sessions resumable
&lt;/h2&gt;

&lt;p&gt;Agent sessions die. If the context vanishes with them, you're back to square one every time, so work logs are kept in two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Snapshot&lt;/strong&gt; (always current, overwritten) … the one page that says "read this and you can resume": current task, target repositories and working branches, touched files, next move, unresolved decisions&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Task log&lt;/strong&gt; (chronological, append-only) … the history of events and decision rationale&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On resume, a launch hook automatically injects the snapshot into context. The agent picks up "from where it left off" without being told anything. If guardrails are the mechanism that prevents accidents, this is the mechanism that prevents losing work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this stands, and what's next
&lt;/h2&gt;

&lt;p&gt;Honestly: this is still &lt;strong&gt;personal tooling&lt;/strong&gt;. It's something I built to put guardrails on my own agent, on my own machine — not a production system.&lt;/p&gt;

&lt;p&gt;But the shape itself — "a session = a declaration of scope + permissions" — has no reason to stay personal. I'm considering &lt;strong&gt;publishing it as a repository&lt;/strong&gt;, installable globally, so anyone's machine can cut sessions the same way. As more people use coding agents daily, the need to &lt;strong&gt;fix "what the agent may and may not touch" as an external declaration&lt;/strong&gt; will only grow, and I'd like this to become a shared foundation for it.&lt;/p&gt;

&lt;p&gt;When you try to control an agent cleverly, you're tempted to write clever prompts. But what actually stops accidents is usually not cleverness — it's the config that makes them &lt;strong&gt;impossible in the first place&lt;/strong&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cilly-yllic.github.io/en/notes/ai-agents/scope-and-permission-guardrails-for-coding-agents/" rel="noopener noreferrer"&gt;cilly-yllic.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>security</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Generating every Firebase layer’s types from a single contract.yml</title>
      <dc:creator>cilly</dc:creator>
      <pubDate>Wed, 22 Jul 2026 12:17:21 +0000</pubDate>
      <link>https://dev.to/cilly/generating-every-firebase-layers-types-from-a-single-contractyml-2egi</link>
      <guid>https://dev.to/cilly/generating-every-firebase-layers-types-from-a-single-contractyml-2egi</guid>
      <description>&lt;p&gt;Sharing types between frontend and backend is an old problem. A while back it was common to keep the two in separate repositories, so you had no choice but to hand-write the same type definitions in each — that is, duplicate them.&lt;/p&gt;

&lt;p&gt;The next stage was private packages. Extracting shared types into a package removes the duplication, but now you have one more repository, and during development you bounce between the frontend, backend, and package repos and editors. Worse, every time you fix a type, nothing reaches the frontend or backend until the package goes through commit → push → version bump → publish. That cycle sits in the middle of every verification loop. Private packages also need separate authentication for CI and deploy pipelines to install them — registry tokens and permissions turn into a surprisingly annoying mechanism. Embedding a shared repo as a git submodule instead just changes the flavor of the chore: every consumer has to advance the referenced commit, and checkout skew happens easily.&lt;/p&gt;

&lt;p&gt;What solved this was the monorepo. Shared libraries can be referenced directly within one repository, and frontend and backend can be developed in parallel. If the problem were just "frontend and backend use the same types," the story would end here.&lt;/p&gt;

&lt;p&gt;But once you adopt an architecture where Cloud SQL (Data Connect) is the source of truth and Firestore is a read projection (cache) — see &lt;a href="https://cilly-yllic.github.io/notes/firebase-gcp/firestore-as-public-cache" rel="noopener noreferrer"&gt;Treating Firestore as a public cache&lt;/a&gt; (Japanese) — the axis of type sharing changes. It's no longer "one type referenced from both sides"; &lt;strong&gt;the same model gets written over and over in different representations, one per layer&lt;/strong&gt;. A monorepo lets you reference the same type from anywhere, but it does nothing to keep definitions that have split into different representations consistent with each other.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same model, written six times
&lt;/h2&gt;

&lt;p&gt;Add a single &lt;code&gt;Product&lt;/code&gt; model and these are the files you hand-write:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The Data Connect GraphQL schema (&lt;code&gt;type Product @table(...)&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The shared TypeScript type (&lt;code&gt;interface Product&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The Zod schema (&lt;code&gt;ProductSchema&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The Firestore projection schema (relations resolved to ids, &lt;code&gt;timestamp&lt;/code&gt; becomes &lt;code&gt;Date&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;The API request / response types and their validation Zod&lt;/li&gt;
&lt;li&gt;The NestJS class-validator DTOs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is a different representation of "the same thing," but nothing verifies them against each other. Every added field means walking through all of these files, and if you forget one, the breakage shows up much later — at runtime. This is not a problem you prevent by being careful; the multi-maintenance structure itself is the cause.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the contract, generate everything else
&lt;/h2&gt;

&lt;p&gt;So I built a tool that &lt;strong&gt;unifies model definitions into a YAML contract file and generates every representation from it&lt;/strong&gt;: &lt;a href="https://github.com/cilly-yllic/my-packages/blob/main/packages/firebase-contract/README.md" rel="noopener noreferrer"&gt;firebase-contract&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhia8qjdstje9v3xubit1.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fhia8qjdstje9v3xubit1.png" alt="Architecture diagram: contract.yml as the single source of truth, compiled through an IR into per-layer code" width="800" height="431"&gt;&lt;/a&gt;&lt;br&gt;
&lt;em&gt;One direction: contract → compiler (IR) → generated artifacts. Drift between artifacts and contract is caught by &lt;code&gt;--check&lt;/code&gt; in CI.&lt;/em&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# contract.yml (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;models&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Product&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;productNo&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;Catalog&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;relation&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;productNo&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;int&lt;/span&gt;
      &lt;span class="na"&gt;title&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;nonempty&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;maxLength&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;200&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ProductStatus&lt;/span&gt;
      &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;ProductMetadata&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;optional&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
      &lt;span class="na"&gt;createdAt&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;timestamp&lt;/span&gt;

&lt;span class="na"&gt;generators&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;generator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;typescript&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#contracts'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;split&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;generator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;zod&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#contracts'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;split&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;generator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;data-connect-graphql&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;src&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;split&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;generator&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;firestore&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;out&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;#contracts'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;split&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A single &lt;code&gt;fbc generate&lt;/code&gt; emits the TS types, Zod schemas, GraphQL schema, and Firestore projection all at once. Constraints like &lt;code&gt;title&lt;/code&gt;'s &lt;code&gt;nonempty: true, maxLength: 200&lt;/code&gt; flow identically into Zod — and, if you declare the API generators (api-validation / api-dto), into request validation and class-validator DTOs as well. "Only the validation is stale" stops happening.&lt;/p&gt;

&lt;p&gt;Contracts can be split with &lt;code&gt;imports&lt;/code&gt;, so in a multi-app monorepo the yml files can follow the repository layout. In the project where I introduced this, the root contract splits into 9 yml files across 2 apps, generating over 30 files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Firestore is a projection, not "another schema"
&lt;/h2&gt;

&lt;p&gt;This is where the tool earns its keep. When Firestore is a read projection, its schema stands in an odd position relative to Data Connect: &lt;strong&gt;not the same, but not unrelated either&lt;/strong&gt;. Relations become resolved string ids, &lt;code&gt;timestamp&lt;/code&gt; becomes &lt;code&gt;Date&lt;/code&gt;, denormalized fields get added. Hand-written, you end up transcribing this "regular transformation plus a few additions" wholesale.&lt;/p&gt;

&lt;p&gt;In the contract, a projection is declared as a &lt;strong&gt;derivation&lt;/strong&gt; from a Data Connect model.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;firestore&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;Product&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Product&lt;/span&gt;
    &lt;span class="na"&gt;collection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;shops/{ws}/.../products/{productNo}&lt;/span&gt;
    &lt;span class="na"&gt;omit&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;catalog&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;log&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;fields&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;linkedCatalogTitle&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;string&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;optional&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;true&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The projection rules — relation → id, timestamp → &lt;code&gt;z.date()&lt;/code&gt; — are applied uniformly by the generator; humans only write &lt;code&gt;pick&lt;/code&gt; / &lt;code&gt;omit&lt;/code&gt; and the extra &lt;code&gt;fields&lt;/code&gt;. Project-wide fields shared across projections can be declared once as &lt;code&gt;fragments:&lt;/code&gt; and inserted into each projection with &lt;code&gt;extends:&lt;/code&gt;. The generated Zod schema reuses fields whose chains are identical to the Data Connect side via &lt;code&gt;.pick()&lt;/code&gt;, so only the parts where the representation actually changes appear in the file. The diff reads as design intent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data Connect's Any boundary
&lt;/h2&gt;

&lt;p&gt;Data Connect stores embedded objects and JSON as an &lt;code&gt;Any&lt;/code&gt; scalar, erasing the logical type. With hand-written schemas, what &lt;code&gt;metadata&lt;/code&gt; "really is" lives in comments and memory. Generating from the contract, the GraphQL side keeps the logical type as a comment (&lt;code&gt;metadata: Any # logical: ProductMetadata&lt;/code&gt;), and typed adapters that convert between the &lt;code&gt;Any&lt;/code&gt; row and the logical type are generated alongside. This is possible because the contract knows where the type-erasing boundaries are.&lt;/p&gt;

&lt;h2&gt;
  
  
  Design decisions that pay off
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generators never see YAML.&lt;/strong&gt; Their only input is an IR (intermediate representation), normalized after parsing and import resolution. Validation is an independent set of rule functions over the IR. Adding a generator requires no changes to existing code, and extensions like OpenAPI output are just a registry entry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I invested in byte-for-byte reproduction of the hand-written files.&lt;/strong&gt; The adoption target was a product already in production; if generated output differed from the existing hand-written files by even one byte, the verification would drown in diffs. The style options that absorb formatting variance and the &lt;code&gt;raw&lt;/code&gt; escape hatch exist for this incremental migration — "reproduce the existing file exactly, then switch it over." Migration became a per-file loop of "lean on generation, confirm the diff is zero" instead of a big-bang rewrite.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Regeneration is idempotent.&lt;/strong&gt; As long as the content doesn't change, files stay byte-for-byte identical, generation timestamp included (&lt;code&gt;generatedAt&lt;/code&gt; carries over from the first generation; only &lt;code&gt;updatedAt&lt;/code&gt; moves when content changes). CI's &lt;code&gt;--check&lt;/code&gt; can mechanically detect contract-code drift precisely because of this idempotency.&lt;/p&gt;

&lt;h2&gt;
  
  
  The costs
&lt;/h2&gt;

&lt;p&gt;The DSL is itself added complexity. Field options and operations have a ceiling of expressiveness, and every time you hit it you choose between growing the generator and escaping through &lt;code&gt;raw&lt;/code&gt;. Debugging generated code is one level more indirect.&lt;/p&gt;

&lt;p&gt;Normally "the team has to learn a new YAML DSL" would count as a cost too, but AI has offset it. Load a coding agent with the DSL rules and the product spec, and an instruction like "add an inventory-count field to Product" gets turned into the contract yml edit by the AI. A declarative contract concentrates model definitions in one place with small diffs, which makes it a friendly target for AI as well. If the output is wrong, it shows up in the &lt;code&gt;fbc generate&lt;/code&gt; diff, so verification is mechanical. What humans need to master is no longer the DSL grammar but "reading the contract and confirming the intent."&lt;/p&gt;

&lt;p&gt;What remains is the expressiveness ceiling and the indirection in debugging — but even minus that, collapsing "add one field" into "add one line to the contract and run &lt;code&gt;fbc generate&lt;/code&gt;" is worth a lot. Multi-maintenance drift moved from "something to be careful about" to "something CI detects." When type consistency shifts from human attention to machinery, review attention also shifts — from transcription-checking individual type definitions to the design of the contract itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not let AI write the type definitions directly?
&lt;/h2&gt;

&lt;p&gt;If AI can write the contract, a further thought suggests itself: hand the LLM the rules and the product spec, let it edit each layer's type definitions directly, and you need neither contract nor generator. If humans never touch the type definitions, doesn't the multi-maintenance chore disappear entirely?&lt;/p&gt;

&lt;p&gt;I think this gets the division of labor backwards. LLM output is probabilistic, so direct edits to six representations carry the risk that something is misaligned every single time. More fundamentally, without a contract &lt;strong&gt;the mechanical definition of "aligned" ceases to exist&lt;/strong&gt;. To verify that six representations express the same model, you need their common origin — something equivalent to the contract. &lt;code&gt;--check&lt;/code&gt; can detect drift because a deterministic generator can state uniquely what the correct output is; AI's direct edits have no such verifiability.&lt;/p&gt;

&lt;p&gt;Review cost changes too. If AI edits six files directly, humans read six files of diffs with suspicion, every time. With the contract approach, humans read only the yml diff, and the rest is derived deterministically. Turning fuzzy specs into a contract is the AI's job; expanding the contract into each representation is the generator's job. It's precisely because the probabilistic layer and the deterministic layer are separated that you can let AI write with confidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it's published
&lt;/h2&gt;

&lt;p&gt;Published as an npm package. Through the RC series I tightened "the contract is authoritative" (unknown keys and out-of-vocabulary values become errors), verified every feature drift-free in a real project, and released v0.1.0 as the first stable version. The DSL's expressiveness and generator details keep improving with feedback from operation.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;npm — &lt;a href="https://www.npmjs.com/package/firebase-contract" rel="noopener noreferrer"&gt;npmjs.com/package/firebase-contract&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;README — &lt;a href="https://github.com/cilly-yllic/my-packages/blob/main/packages/firebase-contract/README.md" rel="noopener noreferrer"&gt;github.com/cilly-yllic/my-packages&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://cilly-yllic.github.io/en/notes/firebase-gcp/contract-driven-firebase-codegen/" rel="noopener noreferrer"&gt;cilly-yllic.github.io&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>firebase</category>
      <category>graphql</category>
      <category>codegen</category>
    </item>
  </channel>
</rss>
