<?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: Ilya Gorban</title>
    <description>The latest articles on DEV Community by Ilya Gorban (@ilya_gorban).</description>
    <link>https://dev.to/ilya_gorban</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%2F4171782%2F7888a3f9-09f5-4fb8-885e-f5f668142ffc.png</url>
      <title>DEV Community: Ilya Gorban</title>
      <link>https://dev.to/ilya_gorban</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ilya_gorban"/>
    <language>en</language>
    <item>
      <title>An AI-Native Dev Lifecycle Across R&amp;D: The Plan</title>
      <dc:creator>Ilya Gorban</dc:creator>
      <pubDate>Mon, 26 Oct 2026 12:24:17 +0000</pubDate>
      <link>https://dev.to/ilya_gorban/an-ai-native-dev-lifecycle-across-rd-the-plan-41nk</link>
      <guid>https://dev.to/ilya_gorban/an-ai-native-dev-lifecycle-across-rd-the-plan-41nk</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; AI coding agents now open pull requests faster than people can review them. Instead of reviewing faster, I plan to decide which PRs need a human at all. A PR review agent puts each PR in one of three risk tiers by written rules: merge on green, AI review with a human after merge, or human review before merge. It starts in &lt;strong&gt;dry run&lt;/strong&gt;: people decide as today, and every disagreement is a finding. After merge, an alert-triage agent links alerts to recent merges and proposes reverts, never merging them. Part 2 will report what survived real PRs.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is an opinionated write-up of what I am planning as a solo DevOps engineer for a whole R&amp;amp;D organization, in one company's setup. It is a plan, not a result. Treat it as reasoning to weigh, not as the one right answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part 1 of a series on an AI-native dev lifecycle:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;This article&lt;/li&gt;
&lt;li&gt;What was adopted, and the first measured results (after implementation)&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Most of our pull requests are now opened by AI coding agents. That changed the shape of the review problem.&lt;/p&gt;

&lt;p&gt;Waiting for a review is not the bottleneck. &lt;strong&gt;Attention is.&lt;/strong&gt; Every PR that asks for a human competes with every other one, and an agent can open a dozen in the time it takes to read one carefully.&lt;/p&gt;

&lt;p&gt;The obvious answers both fail:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Review everything.&lt;/strong&gt; It does not scale, and a reviewer reading their thirtieth agent PR of the week is approving, not reviewing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trust the agent.&lt;/strong&gt; Then nothing stands between a confident, wrong change and production.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Instead: spend human attention where a change can hurt production, and prove with data that the rest is safe without it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Three risk tiers
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tier&lt;/th&gt;
&lt;th&gt;Gate&lt;/th&gt;
&lt;th&gt;Typical changes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;A&lt;/strong&gt;: merge on green&lt;/td&gt;
&lt;td&gt;Required checks only&lt;/td&gt;
&lt;td&gt;Human-facing docs, non-prod overlays, pin-only bumps&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;B&lt;/strong&gt;: AI review, human after merge&lt;/td&gt;
&lt;td&gt;Required checks plus an AI review with no open findings; the owner is notified on merge and can ask for a revert&lt;/td&gt;
&lt;td&gt;Application code, workflow logic, tests, tooling, runtime markdown, agent skills and knowledge&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;C&lt;/strong&gt;: human before merge&lt;/td&gt;
&lt;td&gt;Required checks plus one approval from the owning team&lt;/td&gt;
&lt;td&gt;Prod-grade config, shared CI libraries, infrastructure as code, secrets and permissions, &lt;strong&gt;every database change&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four rules make the tiers hold:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The riskiest file decides.&lt;/strong&gt; A docs edit bundled with a prod overlay is tier C.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A tier can be raised, never lowered&lt;/strong&gt;, and nothing an agent writes can lower it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Default-deny.&lt;/strong&gt; A path that matches no tier list fails a deterministic check, so a new directory blocks until classified instead of silently becoming tier A.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database changes are always C.&lt;/strong&gt; Queries, schema and permissions. In the history I mined, nine in ten database-touching PRs sat in ordinary application paths that a path rule would put in tier B.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The rule seed is the set of paths that already require approval from people with elevated permissions, such as production or infrastructure approvers. It is the closest thing to a written statement of "this path needs these people".&lt;/p&gt;

&lt;h3&gt;
  
  
  Two levels of knowledge
&lt;/h3&gt;

&lt;p&gt;A reviewer, human or AI, needs two kinds of knowledge:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The application.&lt;/strong&gt; The shared conventions every app follows (how code, comments, docs, tests and infrastructure are written, and how a PR is made), plus each app's own contract: its interfaces, where its database contract lives, and &lt;strong&gt;which of its files are runtime&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The change.&lt;/strong&gt; What in this diff needs human eyes, and what can merge on green.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The first level exists because of one finding. Path-only tiering treats &lt;code&gt;*.md&lt;/code&gt; as documentation, wrongly in two places. In one service, every "docs" PR in four weeks edited LLM prompt assets that ship with the service. In another repo, runbooks and agent knowledge files are rendered into a live agent's knowledge base. Both are behaviour changes that look like documentation. &lt;strong&gt;Only the application knows which files it reads at runtime&lt;/strong&gt;, so every app has to declare them before any path rule can be trusted.&lt;/p&gt;

&lt;p&gt;Agent knowledge is the same trap. An edit to a skill that does something important can break it. An in-repo knowledge base that is wordy or wrong bloats every later agent's context, or teaches it the wrong thing. Those files are reviewed for correctness and brevity, not waved through as docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Ground truth from history
&lt;/h3&gt;

&lt;p&gt;A dry run needs the right answer for each PR. History gives a start:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;reverts;&lt;/li&gt;
&lt;li&gt;a follow-up fix PR that references this PR within a week;&lt;/li&gt;
&lt;li&gt;changes requested by a reviewer, or review threads followed by new commits;&lt;/li&gt;
&lt;li&gt;rubber-stamp approvals: no thread, no change, no follow-up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;They are &lt;strong&gt;candidates&lt;/strong&gt;, not labels. On a spot check, a "follow-up fix" was sometimes a real regression and sometimes a sibling change or ordinary iteration. A shared changed file was mostly noise. So people confirm a seed set by hand, one row at a time: should the agent have said "merge" or "human" here, and why.&lt;/p&gt;

&lt;p&gt;And the measure has to cover both directions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;false automerge&lt;/strong&gt;: the agent would have merged something a human needed to see;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;false human&lt;/strong&gt;: the agent would have made a person wait for nothing;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;noise&lt;/strong&gt;: findings nobody acts on, because noisy AI review loses trust faster than no review.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Before merge: a read-only classifier
&lt;/h3&gt;

&lt;p&gt;My proposed first version runs Claude Code headless in a GitHub Action:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A workflow runs &lt;code&gt;claude -p&lt;/code&gt; on each PR, with read-only tools, the repository's rules and the diff.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--json-schema&lt;/code&gt; forces the shape of the verdict: tier, findings with cited evidence, the rules hit, a confidence.&lt;/li&gt;
&lt;li&gt;A small script, not the model, validates that JSON and posts it as a GitHub check. During the dry run the check is neutral.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The design is not tied to it: any agent that runs unattended with schema-shaped output fits, and agents can be compared on the same evaluation set.&lt;/p&gt;

&lt;p&gt;Reviewing a PR that is still changing burns tokens. So the agent reviews only PRs marked ready, after the cheap required checks pass. The shared knowledge base tells every agent session to open PRs as drafts. The platform's own draft state beats a custom label: the ready-for-review event is the trigger, with no extra convention to forget.&lt;/p&gt;

&lt;p&gt;The diff, PR text and comments come from whoever opened the PR: &lt;strong&gt;untrusted input&lt;/strong&gt;. The agent that reads them holds no write token and no cluster access, and the merge decision is enforced by the repository ruleset, outside the model.&lt;/p&gt;

&lt;p&gt;Alternatives I ranked:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Writing my own agent loop on an SDK.&lt;/strong&gt; Full control, but then I own a harness, which is a commodity; what is ours is the knowledge and the evaluation set.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hosted review bots.&lt;/strong&gt; They produce comments rather than a verdict a script can enforce, and our per-application rules do not plug in easily.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  After merge: link alerts to merges
&lt;/h3&gt;

&lt;p&gt;Live tier A and B changes still need watching. The planned first version reuses an alert-triage agent I already run, built on the open-source HolmesGPT, which investigates alerts with runbooks and read-only access. It will gain one step: &lt;strong&gt;did this application deploy a merge shortly before the alert?&lt;/strong&gt; If so, it names the PR, opens a revert PR and pages the owner. None of this is built yet, and the first thing to confirm is that the agent's toolsets can read which revision is actually deployed.&lt;/p&gt;

&lt;p&gt;Three constraints:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It never merges its own revert, and never changes the cluster.&lt;/strong&gt; A revert is a git change like any other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Merge is not deploy.&lt;/strong&gt; It attributes an alert to a merge only once the GitOps controller's synced revision includes the merge commit. A merge still awaiting sync caused nothing yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different trust profile, different agent.&lt;/strong&gt; The pre-merge reviewer reads untrusted diffs and must hold no authority. The post-merge one reads live system state and needs cluster read access. Putting both in one agent would join untrusted input to authority.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Feedback: teach the PR agent from outcomes
&lt;/h3&gt;

&lt;p&gt;A feedback run, started by the agent's maintainer by hand or on a schedule, sets the agent's verdicts over a period against what happened next: human overrides, reverts, follow-up fixes, linked alerts. It proposes rule and knowledge changes as a tier C PR, because changing the PR agent's rules changes what merges unseen. Each confirmed miss joins the evaluation set.&lt;/p&gt;

&lt;h3&gt;
  
  
  The compliance gate
&lt;/h3&gt;

&lt;p&gt;If you hold a security certification, your change-management control almost certainly requires review before a change ships. Automerge under that text and your audit evidence no longer matches what happens.&lt;/p&gt;

&lt;p&gt;So the agent &lt;strong&gt;does not leave dry run&lt;/strong&gt; until the change-management control has been read and either already covers AI review, or has been updated with the auditor's agreement. Check your control text before you automerge anything. My proposed shape is risk-tiered change management: human approval kept for tier C, and for A and B, required automated checks, the AI verdict recorded on the PR, owner notification and a revert window. Whether that satisfies an auditor is the auditor's call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took from published ADLCs, and where I disagree
&lt;/h2&gt;

&lt;p&gt;None of three outside sources targets my problem, more PRs than reviewers; most move human attention earlier, into specs and plans.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"Inside the ADLC"&lt;/strong&gt; (Towards AI) describes a spec-driven pipeline with role-tiered agents. I took three ideas:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;review as several read-only reviewers by dimension&lt;/strong&gt;, each returning structured findings;&lt;/li&gt;
&lt;li&gt;"a reviewer that can change code has a conflict of interest";&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;autonomy as an explicit list of stops&lt;/strong&gt;, which I invert: list what forces human review, and everything else merges on green. That list is also the audit artefact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Where I disagree: no outcome data, it reviews only its own code, and its wrap-up step merges unattended despite saying merging needs approval.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The PDD kit&lt;/strong&gt; keeps one product model per application in Markdown, updated by the agents before code. It is the closest existing shape for my level-1 contract; my history adds runtime files and where the database contract lives. Its evidence is a launch post.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"The Agentic Awakening"&lt;/strong&gt; (a venture investor's playbook) argues that AI review in CI is the cheapest autonomy step, that you should own your context and evaluations and buy the harness, and that a human gate stops prompt injection only when the decision is enforced outside the model. I took all three. I discount its evidence: about twenty interviews, mostly from one investor's portfolio, no control group, and lines of code called a vanity metric, then used as its headline.&lt;/p&gt;

&lt;h2&gt;
  
  
  What part 2 will report
&lt;/h2&gt;

&lt;p&gt;Part 2 will report, against this plan:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Dry-run accuracy&lt;/strong&gt; against the confirmed seed set: false automerge, false human and noise, separately, per tier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Whether path rules plus per-app knowledge&lt;/strong&gt; caught the runtime markdown and database changes that path rules alone missed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tier mix and reviewer load&lt;/strong&gt;: how many tier C requests each reviewer got per week, before and after.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stability&lt;/strong&gt;, using the signals the DORA AI Capabilities Model ties to AI adoption: PR size, reverts of A and B merges, incidents traced to them, rework rate (follow-up fixes within a week) and change failure rate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The post-merge link&lt;/strong&gt;: first, whether the agent can read the deployed revision at all; then how many alerts it tied to a recent merge, and how many of those it got right.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What I dropped or changed&lt;/strong&gt;, and why.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The success bar is fixed now so it cannot drift: fewer tier C requests per reviewer than today's total review requests, with &lt;strong&gt;no production incident traced to a tier A or B merge&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Inside the ADLC (Towards AI): &lt;a href="https://pub.towardsai.net/inside-the-adlc-how-an-ai-native-development-lifecycle-works-8ccd1a52e8ba" rel="noopener noreferrer"&gt;https://pub.towardsai.net/inside-the-adlc-how-an-ai-native-development-lifecycle-works-8ccd1a52e8ba&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;PDD kit (launch post): &lt;a href="https://www.linkedin.com/posts/itaig_a-week-after-i-released-pdd-kit-microsoft-share-7518954454730043392-GQmM/" rel="noopener noreferrer"&gt;https://www.linkedin.com/posts/itaig_a-week-after-i-released-pdd-kit-microsoft-share-7518954454730043392-GQmM/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;The Agentic Awakening: &lt;a href="https://theagenticawakening.com" rel="noopener noreferrer"&gt;https://theagenticawakening.com&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Claude Code headless mode: &lt;a href="https://code.claude.com/docs/en/headless" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/headless&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GitHub rulesets, available rules: &lt;a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets" rel="noopener noreferrer"&gt;https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;HolmesGPT: &lt;a href="https://github.com/robusta-dev/holmesgpt" rel="noopener noreferrer"&gt;https://github.com/robusta-dev/holmesgpt&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;DORA AI Capabilities Model: &lt;a href="https://cloud.google.com/blog/products/ai-machine-learning/introducing-doras-inaugural-ai-capabilities-model" rel="noopener noreferrer"&gt;https://cloud.google.com/blog/products/ai-machine-learning/introducing-doras-inaugural-ai-capabilities-model&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I wrote this article with the help of Claude, an AI assistant. I reviewed and edited it by hand, and it is based on my own hands-on experience.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ai</category>
      <category>codereview</category>
      <category>adlc</category>
    </item>
    <item>
      <title>Testing Crossplane Compositions with Chainsaw: kind Without Credentials, Then a Prod-Like Environment</title>
      <dc:creator>Ilya Gorban</dc:creator>
      <pubDate>Thu, 22 Oct 2026 15:55:15 +0000</pubDate>
      <link>https://dev.to/ilya_gorban/testing-crossplane-compositions-with-chainsaw-kind-without-credentials-then-a-prod-like-bj3</link>
      <guid>https://dev.to/ilya_gorban/testing-crossplane-compositions-with-chainsaw-kind-without-credentials-then-a-prod-like-bj3</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; On top of part 2's offline render tests I added two Kyverno Chainsaw layers. One applies every composition to a throwaway &lt;code&gt;kind&lt;/code&gt; cluster with &lt;strong&gt;no cloud credentials&lt;/strong&gt; on every pull request, so nothing real gets created. The other applies the same use cases with real credentials to a prod-like environment, which mirrors production but can run a branch, and waits for &lt;code&gt;Ready&lt;/code&gt; and verified cloud state. A third job upgrades existing composite resources (XRs) to the PR's compositions. I chose Chainsaw over Uptest, and the new layers found real bugs that every render test had passed.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is an opinionated write-up of what worked for me as a solo DevOps engineer, in one company's setup. Treat it as experience to weigh, not as the one right answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part 3 of a series on Crossplane:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/ilya_gorban/crossplane-black-boxes-for-developer-self-service-32h6"&gt;Crossplane Black Boxes for Developer Self-Service&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/ilya_gorban/writing-and-testing-crossplane-compositions-in-kcl-1708"&gt;Writing and Testing Crossplane Compositions in KCL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;This article&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Part 2 ended with render tests: every composition is rendered offline against JSON fixtures and diffed against recorded expected output. That catches KCL logic errors in milliseconds. It cannot catch anything that needs a reconcile loop: one XR waiting for another to resolve, a provider writing a Secret, a composition that only finishes on its second pass, or deletion leaving nothing behind.&lt;/p&gt;

&lt;p&gt;Before this work, the only live check was a post-sync job waiting on permanent "canary" XRs. It ran &lt;strong&gt;after&lt;/strong&gt; merge, and only after a successful sync, so a failed sync left that revision untested and nobody was told.&lt;/p&gt;

&lt;p&gt;I had put Chainsaw aside once, because my only clusters were shared, the wrong place for test XRDs. That is specific to my case: a dedicated test cluster is a fine answer in general, and with many contributors a dedicated cluster plus Uptest is the safer choice. Reviewing part 2, a reader from the Crossplane community suggested a cheaper middle layer: a local &lt;code&gt;kind&lt;/code&gt; cluster with no cloud credentials. Applying an XR there creates nothing real, so the isolation and cost problems disappear.&lt;/p&gt;

&lt;h2&gt;
  
  
  What
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;Proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1. Render&lt;/td&gt;
&lt;td&gt;KCL + fixtures&lt;/td&gt;
&lt;td&gt;no cluster, CI on every PR&lt;/td&gt;
&lt;td&gt;the composition renders the expected resources&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2. Applied to a cluster&lt;/td&gt;
&lt;td&gt;Chainsaw&lt;/td&gt;
&lt;td&gt;throwaway &lt;code&gt;kind&lt;/code&gt; cluster, no cloud credentials, CI on every PR&lt;/td&gt;
&lt;td&gt;XRs apply, gates hold across reconciles, composed resources are right, deletion is clean&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;3. For real&lt;/td&gt;
&lt;td&gt;Chainsaw&lt;/td&gt;
&lt;td&gt;prod-like environment, with real credentials, before merge&lt;/td&gt;
&lt;td&gt;the cloud accepts it: IAM, real IDs, &lt;code&gt;Ready&lt;/code&gt;, verified state, deletion&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The permanent canaries stay. They are the only check of each environment's own project and IAM, of XRs that live through provider and composition upgrades, and of drift no PR caused. The post-sync job is gone, replaced by an alert that fires when a canary stays not &lt;code&gt;Ready&lt;/code&gt;, or &lt;code&gt;Ready&lt;/code&gt; but unverified, for 30 minutes.&lt;/p&gt;

&lt;h3&gt;
  
  
  One set of use cases
&lt;/h3&gt;

&lt;p&gt;There is one XR manifest per kind in a &lt;code&gt;use-cases/&lt;/code&gt; directory, and both Chainsaw layers apply them, so they can't drift. Negative cases stay inline in the &lt;code&gt;kind&lt;/code&gt; tests, because the prod-like layer applies everything in that directory.&lt;/p&gt;

&lt;h3&gt;
  
  
  Which layer owns a check
&lt;/h3&gt;

&lt;p&gt;The rule I settled on: put each check in the cheapest layer that can see it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Owner&lt;/th&gt;
&lt;th&gt;What goes there&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Render&lt;/td&gt;
&lt;td&gt;every field value, every variant, every &lt;code&gt;assert&lt;/code&gt; (one failing scenario per assert), verified status from mocked observations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kind&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only what needs a cluster: references resolving across XRs, gates over several reconciles, a provider writing a Secret, an &lt;code&gt;assert&lt;/code&gt; surfacing as &lt;code&gt;Synced=False&lt;/code&gt;, deletion leaving no managed resource behind&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prod-like&lt;/td&gt;
&lt;td&gt;what only the cloud decides: IAM, API acceptance, real IDs, &lt;code&gt;Ready&lt;/code&gt;, real verified state&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Every composition has at least one render fixture and one &lt;code&gt;kind&lt;/code&gt; test. A field asserted in both layers is a second place to update, so I don't add those.&lt;/p&gt;

&lt;h2&gt;
  
  
  How
&lt;/h2&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;kind&lt;/code&gt; without credentials
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;kind&lt;/code&gt; job installs the same Crossplane version, providers, functions and compositions as the real clusters, from the same directories. Cloud providers can't authenticate, so reconciling a managed resource (MR) fails at the cloud API, leaving no &lt;code&gt;status.atProvider&lt;/code&gt; and no &lt;code&gt;Ready&lt;/code&gt;, while provider-kubernetes works normally.&lt;/p&gt;

&lt;p&gt;That breaks every composition that finishes on its second pass, such as a grant waiting for its identity. A small script writes &lt;code&gt;status.atProvider&lt;/code&gt; on the cloud MR, in the real provider's ID format; a provider whose observe always fails never overwrites it. The mocks live in the tests and &lt;strong&gt;never in the compositions&lt;/strong&gt;, where a test-only branch would ship to every environment.&lt;/p&gt;

&lt;p&gt;A trimmed test looks like this:&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;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;chainsaw.kyverno.io/v1alpha1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;bucket&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apply&lt;/span&gt;
      &lt;span class="na"&gt;try&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apply&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../../use-cases/bucket.yaml&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;platform.example.com/v1alpha1&lt;/span&gt;
              &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Bucket&lt;/span&gt;
              &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
                &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;example-bucket&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;(conditions[?type == 'Synced'] | [0].status)&lt;/span&gt;&lt;span class="err"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;True'&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;observed&lt;/span&gt;
      &lt;span class="na"&gt;try&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;script&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;content&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;../../lib/mock-observed.sh Bucket example-bucket&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;assert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;file&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assert-verified.yaml&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A run takes about five minutes, and its largest phase is setup: all providers becoming healthy takes roughly 100 to 135 seconds. Tests run in parallel, so the run takes as long as its slowest test. Draft PRs skip the job, and marking the PR ready starts the first run.&lt;/p&gt;

&lt;p&gt;The Crossplane install and the compositions are separate GitOps applications, so a PR changing both can roll out in either order. Such a PR also runs both skewed pairs: new compositions on the old install, and the reverse.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prod-like environment, from a developer's machine
&lt;/h3&gt;

&lt;p&gt;The prod-like layer applies the same use cases, waits for &lt;code&gt;Ready&lt;/code&gt; and verified state, then deletes everything. Three decisions shaped it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs before merge.&lt;/strong&gt; The compositions sync to every environment automatically, so a test after merge reports a break already in production. Branch-testing there already runs the PR's compositions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It runs from a developer's machine, not CI.&lt;/strong&gt; Running in CI would need that environment pinned to the PR first, and that means a workflow running PR code with rights to repoint a GitOps application. I didn't want CI to hold that right, so the script refuses to run unless three things hold:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the cluster context provisions into that environment's own project;&lt;/li&gt;
&lt;li&gt;the branch tracks its own remote branch, has nothing uncommitted and is pushed;&lt;/li&gt;
&lt;li&gt;the environment's application is synced to exactly that commit.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Every cloud-facing name is unique to the run.&lt;/strong&gt; The compositions use the XR's name as the cloud resource name. With fixed names, two runs would fight over one resource, and a run could adopt an existing resource with the same name and then delete it during cleanup. A render step copies the use cases with a per-run suffix on every name and reference.&lt;/p&gt;

&lt;p&gt;Fourteen kinds run in dependency chains, and a consumer is applied only after what it references is &lt;code&gt;Ready&lt;/code&gt; and verified. Kinds configured once per database instance are excluded, because a test copy would fight the real one and its delete would remove it. The alert kind's test rule is rendered so it can never fire. Every gateway the route kind accepts is shared with other services, so instead of throwaway routes it gets the branch test of a real route.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Chainsaw and not Uptest
&lt;/h2&gt;

&lt;p&gt;Uptest is Upbound's tool for exactly this job, and it uses Chainsaw internally. I read its own templates before choosing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On &lt;code&gt;kind&lt;/code&gt;, Uptest waits for the wrong thing.&lt;/strong&gt; Without credentials nothing becomes &lt;code&gt;Ready&lt;/code&gt;, which is what Uptest waits on. The &lt;code&gt;kind&lt;/code&gt; layer proves composed fields, absences and negative cases, which Chainsaw asserts declaratively and Uptest would need as hand-written shell hooks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;On a shared cluster, Uptest's lifecycle is unsafe for me.&lt;/strong&gt; In the version I read:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;every apply downloads and runs a helper script from the project's main branch, unpinned;&lt;/li&gt;
&lt;li&gt;its import step scales every provider's runtime config to zero replicas and back, restarting those providers for the whole cluster;&lt;/li&gt;
&lt;li&gt;delete, by default, waits for every managed resource on the cluster to go away.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With import and update turned off, what is left is apply, wait for &lt;code&gt;Ready&lt;/code&gt;, delete. That is a few Chainsaw steps over the same use cases.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the new layers found
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Three composition bugs that every render test had passed:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A service account composition reported &lt;code&gt;allVerified: true&lt;/code&gt; before anything was observed. The list of resources to verify was empty on the first reconcile, and &lt;code&gt;all([])&lt;/code&gt; is true.&lt;/li&gt;
&lt;li&gt;An alert composition with no log-based metric composed nothing to verify, so it reported verified with nothing checked.&lt;/li&gt;
&lt;li&gt;A readiness gate observed the secret's name instead of the referenced bundle's name. When the two differed, the gate watched an object that doesn't exist, and the dependent resource was never created.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each one became a render scenario first, then a fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A hole in the render harness.&lt;/strong&gt; A failing scenario passed on any non-zero exit, so a scenario that tripped a &lt;em&gt;different&lt;/em&gt; assert, or failed on a broken fixture, still passed. The harness now records the error type and assert message for each failing scenario and diffs them in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A provider is &lt;code&gt;Installed&lt;/code&gt; before its CRDs exist.&lt;/strong&gt; The revision installs managed resource definitions, and a controller creates each CRD afterwards. Gating setup on &lt;code&gt;Installed&lt;/code&gt; failed a run with &lt;code&gt;no matches for kind&lt;/code&gt;. Wait for &lt;code&gt;Healthy&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A mocked status must satisfy the CRD schema.&lt;/strong&gt; The status subresource accepts an invalid patch, but every later full-object update is then rejected. That includes the garbage collector removing its own finalizer, so cleanup hangs. The mock script now validates the whole object with a server-side dry run first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delete with &lt;code&gt;Foreground&lt;/code&gt; propagation.&lt;/strong&gt; With &lt;code&gt;Background&lt;/code&gt;, the namespace is deleted alongside the composed objects. Each object needs a &lt;code&gt;ProviderConfigUsage&lt;/code&gt; in that namespace to finalize, so the namespace deadlocks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Foreground is still not enough.&lt;/strong&gt; The garbage collector blocks the owner only on dependents already in its cache, so an MR can outlive its XR. Each test's first step has a cleanup that waits for every managed kind to go; cleanup runs in reverse, so it runs last.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assert every composed resource before the test ends.&lt;/strong&gt; Otherwise cleanup can delete the XR while a reconcile is still applying, and the resource created in that window outlives it. This only appeared when I raised parallelism, because load widens the window.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Interrupted runs leave real resources.&lt;/strong&gt; Chainsaw documents cleanup after a failed step, not after a killed process, and laptops get interrupted. The script reports earlier leftovers before starting and prints its run ID on Ctrl-C, and a daily orphan-detection job covers cloud resources whose XR is gone.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Upgrade testing
&lt;/h2&gt;

&lt;p&gt;Everything above tests a change on fresh XRs. Many breaks hit XRs that already exist: a new composition revision re-renders them, a renamed composed resource is deleted and recreated, a removed field disappears. So when the compositions change, the &lt;code&gt;kind&lt;/code&gt; workflow runs one more job:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Install the main branch's tree and run its tests without cleanup, so their XRs stay.&lt;/li&gt;
&lt;li&gt;Install the PR's tree over them.&lt;/li&gt;
&lt;li&gt;Fail if any XR or composed resource was recreated (UIDs compared), or an XR is no longer synced and verified.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;An intended recreation goes in a reviewed list file. As a negative control, renaming one composed resource failed exactly that kind's XRs. The job adds about eight minutes.&lt;/p&gt;

&lt;p&gt;When a changed field makes the cloud provider replace the external resource, the MR keeps its UID, so that case is covered by the prod-like branch test and the permanent canaries instead.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] One use-case manifest per kind, shared by the &lt;code&gt;kind&lt;/code&gt; and prod-like layers.&lt;/li&gt;
&lt;li&gt;[ ] Each check lives in the cheapest layer that can see it.&lt;/li&gt;
&lt;li&gt;[ ] Mocks only in tests, in the real provider's ID format, validated against the CRD schema.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;Foreground&lt;/code&gt; deletion, plus an explicit wait for every managed kind before namespace delete.&lt;/li&gt;
&lt;li&gt;[ ] Every composed resource asserted before the test ends.&lt;/li&gt;
&lt;li&gt;[ ] The prod-like layer runs before merge, on unique names, only against the PR's exact commit.&lt;/li&gt;
&lt;li&gt;[ ] Upgrade job: main's tree first, then the PR's, with no unexpected recreations.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kyverno Chainsaw: &lt;a href="https://kyverno.github.io/chainsaw/" rel="noopener noreferrer"&gt;https://kyverno.github.io/chainsaw/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Upbound Uptest: &lt;a href="https://github.com/crossplane/uptest" rel="noopener noreferrer"&gt;https://github.com/crossplane/uptest&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;kind: &lt;a href="https://kind.sigs.k8s.io/" rel="noopener noreferrer"&gt;https://kind.sigs.k8s.io/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Kubernetes foreground deletion: &lt;a href="https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion" rel="noopener noreferrer"&gt;https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I wrote this article with the help of Claude, an AI assistant. I reviewed and edited it by hand, and it is based on my own hands-on experience.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>crossplane</category>
      <category>testing</category>
    </item>
    <item>
      <title>Writing and Testing Crossplane Compositions in KCL</title>
      <dc:creator>Ilya Gorban</dc:creator>
      <pubDate>Tue, 13 Oct 2026 10:19:35 +0000</pubDate>
      <link>https://dev.to/ilya_gorban/writing-and-testing-crossplane-compositions-in-kcl-1708</link>
      <guid>https://dev.to/ilya_gorban/writing-and-testing-crossplane-compositions-in-kcl-1708</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; I write every Crossplane Composition in KCL via &lt;code&gt;function-kcl&lt;/code&gt;. Shared logic lives in one helper module mounted from a ConfigMap. Editing happens in a real &lt;code&gt;.k&lt;/code&gt; file with IDE support, then the code goes back into the YAML. Every composition is rendered in CI against JSON fixtures and diffed against recorded &lt;strong&gt;expected output&lt;/strong&gt;, and that check also verifies two things &lt;code&gt;kcl run&lt;/code&gt; cannot see: XRD status-schema conformance and multi-version compatibility.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is an opinionated write-up of what worked for me as a solo DevOps engineer, in one company's setup. Treat it as experience to weigh, not as the one right answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part 2 of a series on Crossplane:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;a href="https://dev.to/ilya_gorban/crossplane-black-boxes-for-developer-self-service-32h6"&gt;Crossplane Black Boxes for Developer Self-Service&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;This article&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/ilya_gorban/testing-crossplane-compositions-with-chainsaw-kind-without-credentials-then-a-prod-like-bj3"&gt;Testing Crossplane Compositions with Chainsaw: kind Without Credentials, Then a Prod-Like Environment&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;I started with a mix of &lt;code&gt;function-patch-and-transform&lt;/code&gt; and KCL, then moved everything to KCL. KCL covers all our needs: loops, conditionals, functions, asserts. An audit of our 12 compositions found the runtime was fine. The real cost was &lt;strong&gt;maintenance&lt;/strong&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Duplicated block&lt;/th&gt;
&lt;th&gt;Compositions carrying it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Resolve a GCP SA email by observing a K8s ServiceAccount annotation&lt;/td&gt;
&lt;td&gt;7 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Header boilerplate (&lt;code&gt;oxr&lt;/code&gt; / &lt;code&gt;ocds&lt;/code&gt; / name / namespace / spec)&lt;/td&gt;
&lt;td&gt;12 of 12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content-key sanitiser &lt;code&gt;.replace("/","-").replace(".","-")&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;~5&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Fixing a bug in the SA-resolve block meant editing seven files. And KCL embedded as a YAML block scalar has no highlighting or completion, and indentation mistakes show up only when the function pod fails.&lt;/p&gt;

&lt;p&gt;Everything below was added later, mostly after a specific escape.&lt;/p&gt;

&lt;h2&gt;
  
  
  What
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  subgraph repo[GitOps repo]
    H[kcl-helpers/ module&amp;lt;br/&amp;gt;+ kcl test]
    C[composition-*.yaml&amp;lt;br/&amp;gt;inline KCL source]
    F[tests/*.json fixtures]
    E[tests/expected/&amp;amp;lt;comp&amp;amp;gt;/&amp;amp;lt;scenario&amp;amp;gt;.yaml]
  end
  H --&amp;gt;|ConfigMap, mounted at /kcl-helpers| FK[function-kcl pod]
  C --&amp;gt; FK
  H &amp;amp; C &amp;amp; F --&amp;gt; R[run.sh: kcl run per scenario] --&amp;gt;|diff| E&lt;/code&gt;&lt;/pre&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pipeline:&lt;/strong&gt; each Composition is &lt;code&gt;mode: Pipeline&lt;/code&gt; with two steps. A &lt;code&gt;function-kcl&lt;/code&gt; render step does all the logic, and &lt;code&gt;function-auto-ready&lt;/code&gt; handles readiness.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared module&lt;/strong&gt; &lt;code&gt;helpers&lt;/code&gt;: KSA-email resolve, an observe-&lt;code&gt;Object&lt;/code&gt; scaffold, the content-key sanitiser, an ownerRef helper and a status helper. Compositions use &lt;code&gt;import helpers as h&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delivery choice:&lt;/strong&gt; I evaluated an OCI package against a ConfigMap. I picked the &lt;strong&gt;ConfigMap&lt;/strong&gt;: no registry, no publish step, versioned by git history. The trade-off is that the ConfigMap must sync before the compositions that use it (see Gotchas).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blast radius kept small on purpose:&lt;/strong&gt; only the grant compositions import the module. The simple leaf compositions stay self-contained.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Editing: extract, edit in an IDE, put it back
&lt;/h3&gt;

&lt;p&gt;The KCL lives at &lt;code&gt;spec.pipeline[N].input.spec.source&lt;/code&gt;. I extract it to a sibling &lt;code&gt;.k&lt;/code&gt; file, edit it with the KCL VS Code extension, and write it back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# extract
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;
&lt;span class="n"&gt;doc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;yaml&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;safe_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;composition-x.yaml&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="n"&gt;src&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;input&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;doc&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;spec&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pipeline&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
           &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;functionRef&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;function-kcl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;composition-x.k&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;w&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;src&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reintegrating is the part people get wrong. &lt;strong&gt;Never &lt;code&gt;yaml.dump()&lt;/code&gt; the document back&lt;/strong&gt;, because it destroys comments and key order. Instead I string-replace the block. I detect the indent of &lt;code&gt;source: |&lt;/code&gt; from the file (never hard-code 12 spaces), re-indent the new source by that amount plus 2, and substitute only that block. Then I &lt;strong&gt;verify the round trip&lt;/strong&gt;: re-parse the YAML and assert the extracted source is byte-equal to the &lt;code&gt;.k&lt;/code&gt; file. An off-by-one indent silently breaks KCL parsing, and only the round-trip check catches it before the cluster does.&lt;/p&gt;

&lt;p&gt;For completion on &lt;code&gt;option("params")&lt;/code&gt;, drop a minimal &lt;code&gt;kcl.mod&lt;/code&gt; next to the &lt;code&gt;.k&lt;/code&gt; file. If the composition imports the helpers, that &lt;code&gt;kcl.mod&lt;/code&gt; needs a &lt;code&gt;[dependencies]&lt;/code&gt; entry pointing at the module directory. Without it, nothing runs locally (&lt;code&gt;CannotFindModule&lt;/code&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Fixtures: one JSON per composition, one key per scenario
&lt;/h3&gt;

&lt;p&gt;Each top-level key is a scenario. Its value is exactly what &lt;code&gt;option("params")&lt;/code&gt; returns at runtime:&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;"a_creating"&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;"oxr"&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;"apiVersion"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"platform.example.io/v1alpha1"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"spec"&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="nl"&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;"..."&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;span class="nl"&gt;"b_email_resolved"&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;"oxr"&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="nl"&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;"..."&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ocds"&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;"my-sa-ksa-observe"&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;"Resource"&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;"status"&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;"atProvider"&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;"manifest"&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;"metadata"&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;"annotations"&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;"iam.gke.io/gcp-service-account"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&amp;lt;gcp-sa-email&amp;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;"err_missing_project"&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;"oxr"&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="nl"&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;"..."&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;Coverage rules, enforced in review:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;At least one scenario with &lt;strong&gt;no &lt;code&gt;ocds&lt;/code&gt;&lt;/strong&gt; (first reconcile) and one with &lt;strong&gt;&lt;code&gt;ocds&lt;/code&gt; populated&lt;/strong&gt; (second and later reconciles). Most of my bugs lived in the gap between those two passes.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;err_*&lt;/code&gt; scenario per &lt;code&gt;assert&lt;/code&gt;. It must fail &lt;strong&gt;with that assert's message&lt;/strong&gt;, not merely exit non-zero. A missing module, a syntax error or a malformed fixture also exits non-zero and would otherwise "pass".&lt;/li&gt;
&lt;li&gt;If the composition reads &lt;code&gt;extraResources&lt;/code&gt;, cover both "fetched" and "not fetched yet".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reordered-twin scenario&lt;/strong&gt; for every composition that iterates a list: same items, reversed order, identical set of composed-resource names (see content-keyed naming below).&lt;/li&gt;
&lt;li&gt;Every &lt;strong&gt;served&lt;/strong&gt; XRD version needs at least one scenario whose &lt;code&gt;oxr.apiVersion&lt;/code&gt; is that version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One scenario mirrors a live XR exactly.&lt;/strong&gt; A rarely set optional field broke the render only on real input (the &lt;code&gt;**&lt;/code&gt; trap below).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replacing a specialised composition?&lt;/strong&gt; Feed the new one the old XR's live inputs and require identical rule uids.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. &lt;code&gt;run.sh&lt;/code&gt;: render, diff against expected output, fail on drift
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./run.sh            &lt;span class="c"&gt;# CI mode: render every scenario, diff against tests/expected/&lt;/span&gt;
./run.sh &lt;span class="nt"&gt;--update&lt;/span&gt;   &lt;span class="c"&gt;# re-record expected output after an INTENTIONAL change&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For each composition with a fixture, it extracts the source with &lt;code&gt;yq&lt;/code&gt;. It writes a temp &lt;code&gt;kcl.mod&lt;/code&gt; whose &lt;code&gt;[dependencies]&lt;/code&gt; points at the repo's helper directory, which is the same files the in-cluster ConfigMap is built from, so local and cluster resolve the same code. Then it runs &lt;code&gt;kcl run&lt;/code&gt; per scenario and &lt;code&gt;diff -u&lt;/code&gt;s the result against &lt;code&gt;tests/expected/&amp;lt;composition&amp;gt;/&amp;lt;scenario&amp;gt;.yaml&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The same script runs two extra checks that a render alone can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Status declared in the XRD.&lt;/strong&gt; Crossplane XRD schemas are structural. If the composition writes &lt;code&gt;dxr.status.foo&lt;/code&gt; and the XRD doesn't declare &lt;code&gt;foo&lt;/code&gt;, the composite's status apply fails &lt;strong&gt;on every reconcile, forever&lt;/strong&gt;. The composed MRs are still created, so everything looks green except a reconcile-error alert. This reached production once. The check walks each rendered composite's &lt;code&gt;.status&lt;/code&gt; keys against the XRD's &lt;code&gt;status&lt;/code&gt; schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-version invariant.&lt;/strong&gt; The composition only ever sees the XR at the &lt;em&gt;referenceable&lt;/em&gt; version, so that version's spec must be a superset of every other served version's fields. Otherwise a field exposed on a newer version is invisible to the KCL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The helper module has its own unit tests (&lt;code&gt;kcl test&lt;/code&gt;). They catch invariants no fixture happens to exercise. My example: siblings that differ only in a name suffix must not collide after sanitising.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. CI
&lt;/h3&gt;

&lt;p&gt;A path-filtered GitHub Actions workflow runs on PRs touching the compositions, the helper module, the workflow itself, or the repo's tool-version file:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the KCL CLI version &lt;strong&gt;from the repo's tool-version file&lt;/strong&gt;; I once had three KCL pins in three places.&lt;/li&gt;
&lt;li&gt;Assert it equals the helper module's &lt;code&gt;kcl.mod&lt;/code&gt; &lt;code&gt;edition&lt;/code&gt;, and fail with a pointer to the docs if not.&lt;/li&gt;
&lt;li&gt;Install that KCL version, run &lt;code&gt;kcl test&lt;/code&gt; on the helpers, then &lt;code&gt;run.sh&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  5. Beyond unit tests: live e2e and canaries
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live e2e:&lt;/strong&gt; a small Helm chart keeps one permanent instance of every XR kind on real clusters, plus a PostSync &lt;code&gt;Job&lt;/code&gt; that &lt;code&gt;kubectl wait&lt;/code&gt;s each one for &lt;code&gt;Ready&lt;/code&gt; and asserts its connection secret. The chart has &lt;code&gt;helm unittest&lt;/code&gt; tests too. Its Role must list every XR kind it waits on. Add a new kind without updating RBAC and the test fails with &lt;code&gt;Forbidden&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary for non-additive changes:&lt;/strong&gt; every XR floats on &lt;code&gt;compositionUpdatePolicy: Automatic&lt;/code&gt; by default, so a merged Composition change rolls out to &lt;strong&gt;every XR of that type&lt;/strong&gt; on the next reconcile. For anything that renames composed resources, touches immutable fields or migrates grants, I use channel labels. A label on the Composition alone protects nothing: an &lt;code&gt;Automatic&lt;/code&gt; XR with no selector always takes the latest revision. So consumers first select &lt;code&gt;channel: stable&lt;/code&gt; via &lt;code&gt;spec.crossplane.compositionRevisionSelector.matchLabels&lt;/code&gt;, the changed Composition is labelled &lt;code&gt;channel: canary&lt;/code&gt;, one test XR selects &lt;code&gt;canary&lt;/code&gt;, and I promote (relabel to &lt;code&gt;stable&lt;/code&gt;) only after it reconciles cleanly.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  KCL language traps (hit in practice)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Trap&lt;/th&gt;
&lt;th&gt;Do this instead&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;d.get("k") or {}&lt;/code&gt; always gives &lt;code&gt;{}&lt;/code&gt;, because &lt;code&gt;get&lt;/code&gt; returns &lt;code&gt;Undefined&lt;/code&gt;, which is falsy&lt;/td&gt;
&lt;td&gt;&lt;code&gt;d["k"] if "k" in d else {}&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;A bare ternary dict entry (&lt;code&gt;k = a if c else b&lt;/code&gt;) once dropped the entry after it; simple cases don't reproduce, so this is defensive&lt;/td&gt;
&lt;td&gt;wrap it: &lt;code&gt;k = (a if c else b)&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;{**base, key: v}&lt;/code&gt; or &lt;code&gt;{**base, "key": v}&lt;/code&gt; raises &lt;code&gt;conflicting values&lt;/code&gt; if &lt;code&gt;base&lt;/code&gt; already has &lt;code&gt;key&lt;/code&gt; (&lt;code&gt;:&lt;/code&gt; unifies, it doesn't override)&lt;/td&gt;
&lt;td&gt;override with &lt;code&gt;=&lt;/code&gt;: &lt;code&gt;{**base, key = v}&lt;/code&gt;, or union: `base \&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;{% raw %}&lt;code&gt;any([...])&lt;/code&gt; is a parse error, because &lt;code&gt;any&lt;/code&gt; is a quantifier keyword&lt;/td&gt;
&lt;td&gt;&lt;code&gt;len([x for x in xs if cond]) &amp;gt; 0&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;ocds[name].resource&lt;/code&gt; returns &lt;code&gt;Undefined&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;capital R: &lt;code&gt;ocds[name].Resource&lt;/code&gt; (Go struct, no json tag)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;KCLRun &lt;code&gt;spec.dependencies&lt;/code&gt; with your own &lt;code&gt;[dependencies]&lt;/code&gt; header gives a duplicate TOML key, and every importing XR goes &lt;code&gt;Synced=False&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;give &lt;strong&gt;only&lt;/strong&gt; the entry line: &lt;code&gt;helpers = { path = "/kcl-helpers" }&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Content-keyed names: the rule that prevents an outage
&lt;/h2&gt;

&lt;p&gt;When a composition emits one resource per list item, name it by a &lt;strong&gt;sanitised content key&lt;/strong&gt; (&lt;code&gt;sa-iam-roles-cloudsql-client&lt;/code&gt;), never by enumerate index (&lt;code&gt;sa-iam-0&lt;/code&gt;). Composed resources have immutable key fields (&lt;code&gt;ProjectIAMMember.role&lt;/code&gt;, a Grant's role). With index names, inserting an item at the top re-maps every existing MR onto a different role, and the provider refuses: &lt;code&gt;cannot change the value of the argument "role"&lt;/code&gt;. Switching an existing composition to content keys renames every live MR once, which is a brief delete/recreate per binding. Roll that out env by env.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pros / cons
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real language: functions, asserts, loops&lt;/td&gt;
&lt;td&gt;One more language for the team to learn (it is not Python)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Render tests need no cluster and run in seconds&lt;/td&gt;
&lt;td&gt;Fixtures must model &lt;code&gt;ocds&lt;/code&gt; shapes by hand&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Expected-output diff proves a refactor is byte-identical&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;--update&lt;/code&gt; can become a rubber stamp&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shared module removes 7-way duplication&lt;/td&gt;
&lt;td&gt;Helpers and compositions ship from two ArgoCD apps&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;When not to bother:&lt;/strong&gt; with two or three trivial compositions, patch-and-transform plus a smoke test in a prod-like environment is enough. The harness pays off once compositions branch on observed state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas / lessons learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;run.sh&lt;/code&gt; does not test function-kcl's &lt;code&gt;dependencies&lt;/code&gt; parsing.&lt;/strong&gt; It builds its own &lt;code&gt;kcl.mod&lt;/code&gt;. A malformed &lt;code&gt;dependencies&lt;/code&gt; field in the Composition is only caught by a branch-test in a prod-like environment, so always branch-test a shared-module change.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Helpers and compositions are a multi-app change.&lt;/strong&gt; The helper ConfigMap ships with the app that installs Crossplane, and the compositions with the app that defines the XRDs. Pin and sync only one in a branch test, and every XR goes &lt;code&gt;Degraded&lt;/code&gt; with &lt;code&gt;failed to run kcl function pipelines&lt;/code&gt;. The ConfigMap name is stable, so once it syncs the mount refreshes within about 30 to 60 seconds and XRs recover without a restart.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The helper mount is configured twice.&lt;/strong&gt; &lt;code&gt;function-kcl&lt;/code&gt; has its own DeploymentRuntimeConfig (it needs much more memory) next to the default one used by the other functions. Change the mount in both.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;--update&lt;/code&gt; keeps the stale file on a render error.&lt;/strong&gt; A scenario that hits an evaluation error is not re-recorded, so &lt;code&gt;git status&lt;/code&gt; shows zero changes and the edit looks like a no-op. Run plain &lt;code&gt;./run.sh&lt;/code&gt; first and look for &lt;code&gt;render error&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Never run &lt;code&gt;--update&lt;/code&gt; twice concurrently.&lt;/strong&gt; Contention on the KCL package cache prints &lt;code&gt;waiting for package-cache lock...&lt;/code&gt; to stdout, and it gets baked into expected files for compositions you never touched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the re-recorded expected output before committing.&lt;/strong&gt; Check the new resource is present with the right fields, and &lt;em&gt;absent&lt;/em&gt; from scenarios that shouldn't have it. &lt;code&gt;--update&lt;/code&gt; re-records everything, so an unexpected file in &lt;code&gt;git status&lt;/code&gt; means you changed another composition's render.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gate dependent MRs on the observation, not just on other inputs.&lt;/strong&gt; One composition fell back to the XR's &lt;em&gt;name&lt;/em&gt; as the bucket name before the referenced bucket was observed, emitted a ForceNew field with the wrong value, and wedged the MR.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Composition metadata patches don't land; status does.&lt;/strong&gt; &lt;code&gt;dxr.metadata.labels&lt;/code&gt; never reaches the composite. For sibling discovery, fetch all composites with an empty &lt;code&gt;matchLabels&lt;/code&gt; and filter in KCL on a &lt;em&gt;declared&lt;/em&gt; status field.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name fixtures after the XRD kind&lt;/strong&gt; (&lt;code&gt;project-grant&lt;/code&gt;), not the cloud service. A file named after the service hid a composition from &lt;code&gt;grep &amp;lt;Kind&amp;gt;&lt;/code&gt;, and that led to a wrong conclusion that a provider was unused.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] KCL edited in a &lt;code&gt;.k&lt;/code&gt; file, reintegrated by string replace, round trip verified.&lt;/li&gt;
&lt;li&gt;[ ] Fixture covers first pass, resolved pass, every &lt;code&gt;assert&lt;/code&gt;, reordered twin, every served version.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;./run.sh&lt;/code&gt; green with &lt;strong&gt;no&lt;/strong&gt; &lt;code&gt;--update&lt;/code&gt;. Then &lt;code&gt;--update&lt;/code&gt; once, then read the diff.&lt;/li&gt;
&lt;li&gt;[ ] Status fields declared in the XRD (the harness checks).&lt;/li&gt;
&lt;li&gt;[ ] KCL version single-sourced. CI asserts CLI equals module edition.&lt;/li&gt;
&lt;li&gt;[ ] Shared-module or non-additive change: prod-like branch-test of &lt;em&gt;both&lt;/em&gt; apps, canary channel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;function-kcl: &lt;a href="https://github.com/crossplane-contrib/function-kcl" rel="noopener noreferrer"&gt;https://github.com/crossplane-contrib/function-kcl&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;KCL language tour: &lt;a href="https://kcl-lang.io/docs/reference/lang/tour" rel="noopener noreferrer"&gt;https://kcl-lang.io/docs/reference/lang/tour&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Crossplane composition revisions: &lt;a href="https://docs.crossplane.io/latest/composition/composition-revisions/" rel="noopener noreferrer"&gt;https://docs.crossplane.io/latest/composition/composition-revisions/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Crossplane XRD status fields: &lt;a href="https://docs.crossplane.io/latest/composition/composite-resource-definitions/" rel="noopener noreferrer"&gt;https://docs.crossplane.io/latest/composition/composite-resource-definitions/&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Update, 14 October 2026
&lt;/h2&gt;

&lt;p&gt;Thanks to a reader from the Crossplane community who reviewed this article and answered part of my open questions about Chainsaw and Uptest. The piece I was missing: run the cluster layer on a local kind cluster with no cloud credentials, so applying an XR creates no real resources. That gives a fast, isolated loop between the offline render tests and a final "for real" run with credentials in CI, and it removes most of the reasons I had for putting those tools aside.&lt;/p&gt;

&lt;p&gt;I'm going to try it. A follow-up article will describe either the revised testing setup, or the problems I couldn't get past and why I stopped there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Follow-up:&lt;/strong&gt; the revised setup is in &lt;a href="https://dev.to/ilya_gorban/testing-crossplane-compositions-with-chainsaw-kind-without-credentials-then-a-prod-like-bj3"&gt;part 3&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I wrote this article with the help of Claude, an AI assistant. I reviewed and edited it by hand, and it is based on my own hands-on experience.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>crossplane</category>
      <category>testing</category>
    </item>
    <item>
      <title>Crossplane Black Boxes for Developer Self-Service</title>
      <dc:creator>Ilya Gorban</dc:creator>
      <pubDate>Tue, 13 Oct 2026 10:19:18 +0000</pubDate>
      <link>https://dev.to/ilya_gorban/crossplane-black-boxes-for-developer-self-service-32h6</link>
      <guid>https://dev.to/ilya_gorban/crossplane-black-boxes-for-developer-self-service-32h6</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; Developers create cloud resources and IAM grants themselves by committing a small namespaced YAML next to their app. Each kind is a Crossplane XRD (a "black box"): the schema is the whole contract, the composition behind it belongs to the platform team, and the controls that matter (allowed roles, which project, who can be granted, what happens on delete) are enforced by the API server rather than by review. The same docs that teach humans also let a coding agent turn "my worker needs to read the uploads bucket" into the correct manifests.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is an opinionated write-up of what worked for me as a solo DevOps engineer, in one company's setup. Treat it as experience to weigh, not as the one right answer.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is part 1 of a series on Crossplane:&lt;/em&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;This article&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/ilya_gorban/writing-and-testing-crossplane-compositions-in-kcl-1708"&gt;Writing and Testing Crossplane Compositions in KCL&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/ilya_gorban/testing-crossplane-compositions-with-chainsaw-kind-without-credentials-then-a-prod-like-bj3"&gt;Testing Crossplane Compositions with Chainsaw: kind Without Credentials, Then a Prod-Like Environment&lt;/a&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Why
&lt;/h2&gt;

&lt;p&gt;Without self-service, every bucket, service account or role binding for an app is a ticket to the platform team and a Terraform change in the IaC repo. That has three costs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Latency.&lt;/strong&gt; A one-line IAM grant waits for someone on the platform team.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift in ownership.&lt;/strong&gt; App config lives in the GitOps manifests repo, but its cloud dependencies live in Terraform, reviewed by different people at different times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-broad fallbacks.&lt;/strong&gt; An "unrestricted" fallback tier tends to end up editor-level; a restricted tier covering every real need removes the reason for one.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is self-service that cannot be used to do the wrong thing, rather than self-service that someone has to police.&lt;/p&gt;

&lt;h2&gt;
  
  
  What
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  Dev[App PR: crossplane.yaml] --&amp;gt; Argo[ArgoCD app&amp;lt;br/&amp;gt;AppProject allows only platform.* kinds]
  Argo --&amp;gt; XR[Namespaced XR&amp;lt;br/&amp;gt;schema = contract]
  XR --&amp;gt; Comp[Composition&amp;lt;br/&amp;gt;function-kcl]
  Comp --&amp;gt; MR[Managed resources&amp;lt;br/&amp;gt;cloud providers]
  MR --&amp;gt; GCP[(GCP project&amp;lt;br/&amp;gt;via scoped provider identity)]
  Comp --&amp;gt; Secret[Connection Secret&amp;lt;br/&amp;gt;in the app namespace]&lt;/code&gt;&lt;/pre&gt;



&lt;h3&gt;
  
  
  Two layers, never mixed
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Kinds (generic names)&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1: provision&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;Identity&lt;/code&gt;, &lt;code&gt;Bucket&lt;/code&gt;, &lt;code&gt;Topic&lt;/code&gt;, &lt;code&gt;Subscription&lt;/code&gt;, &lt;code&gt;Dataset&lt;/code&gt;, &lt;code&gt;SecretBundle&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Manages the resource &lt;strong&gt;only&lt;/strong&gt;. Never its IAM.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2: grant&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ProjectGrant&lt;/code&gt;, &lt;code&gt;BucketGrant&lt;/code&gt;, &lt;code&gt;TopicGrant&lt;/code&gt;, &lt;code&gt;DatasetGrant&lt;/code&gt;, &lt;code&gt;DatabaseGrant&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;Additive&lt;/strong&gt; IAM only (&lt;code&gt;*IAMMember&lt;/code&gt;), one grant target per XR, granted to an in-cluster &lt;code&gt;Identity&lt;/code&gt; XR via &lt;code&gt;identityRef&lt;/code&gt;.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;DatabaseGrant&lt;/code&gt; is the one deliberate exception: it creates the database IAM user &lt;em&gt;and&lt;/em&gt; the SQL grants, because those two are inseparable.&lt;/p&gt;

&lt;p&gt;The split exists for a concrete reason. If a layer-1 resource manages an &lt;em&gt;authoritative&lt;/em&gt; IAM field (for example &lt;code&gt;access[]&lt;/code&gt; on a BigQuery dataset), it fights the additive layer-2 grants and the dataset's IAM oscillates. I saw a grant flap in and out for over a day (172 &lt;code&gt;UpdatedExternalResource&lt;/code&gt; events in 28h) until I stopped the dataset MR from late-initialising &lt;code&gt;access&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Claimless v2
&lt;/h3&gt;

&lt;p&gt;I use Crossplane &lt;strong&gt;v2 namespaced XRs&lt;/strong&gt;, not v1 claims: one object in the app's namespace, with its connection Secret beside it, removes all cross-namespace secret plumbing.&lt;/p&gt;

&lt;h2&gt;
  
  
  How: the guardrails, layer by layer
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Developers can only create the black boxes
&lt;/h3&gt;

&lt;p&gt;The ArgoCD &lt;code&gt;AppProject&lt;/code&gt; for app teams whitelists the XR API group (&lt;code&gt;platform.example.io&lt;/code&gt;, kind &lt;code&gt;*&lt;/code&gt;) and &lt;strong&gt;not&lt;/strong&gt; the provider groups (&lt;code&gt;*.gcp.m.upbound.io&lt;/code&gt;, &lt;code&gt;*.gcp.upbound.io&lt;/code&gt;). A developer cannot sneak a raw &lt;code&gt;ProjectIAMMember&lt;/code&gt; into their kustomization: ArgoCD refuses to sync it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. The schema is the policy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# xrd: ProjectGrant (excerpt)&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;required&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;identityRef&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;project&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;properties&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;identityRef&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;object&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;required&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;properties&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="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="na"&gt;project&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="na"&gt;roles&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;array&lt;/span&gt;
      &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;string&lt;/span&gt;
        &lt;span class="na"&gt;enum&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;roles/storage.objectViewer&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;roles/pubsub.viewer&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;roles/logging.viewer&lt;/span&gt;
          &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;roles/monitoring.viewer&lt;/span&gt;
          &lt;span class="c1"&gt;# ... only what a real request needed, never *.admin&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Role allow-list as an enum.&lt;/strong&gt; Start with read-only roles and add a write role only when a real request needs it. No &lt;code&gt;*.admin&lt;/code&gt;, nothing system-only. Adding a role is additive and safe. Removing one breaks every XR that uses it, so the list starts small and only grows on request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;identityRef&lt;/code&gt; only, no raw &lt;code&gt;member&lt;/code&gt;.&lt;/strong&gt; A composition only ever grants to an identity that Crossplane itself manages in that cluster. Groups, humans and external or cross-project identities go through Terraform, where a reviewer sees them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Patterns at admission.&lt;/strong&gt; Connection-secret names are constrained by a &lt;code&gt;pattern&lt;/code&gt; (for example &lt;code&gt;"^[a-z][a-z0-9-]*-conn$"&lt;/code&gt;), and identity names have their own limits.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-env schema narrowing.&lt;/strong&gt; An env overlay can patch the XRD itself: a narrower enum on non-prod clusters keeps them out of shared prod-grade projects entirely.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Safe deletion defaults.&lt;/strong&gt; Resource-creating MRs default to orphan on delete (buckets, datasets, topics and database users survive an accidental &lt;code&gt;kubectl delete&lt;/code&gt;). Grants and IAM members default to &lt;em&gt;delete&lt;/em&gt;, so removing a grant XR really does revoke access. A copy-pasted orphan policy on IAM-member MRs means deleting the XR silently leaves the cloud grant in place. Check this field in review. (v2 namespaced MRs have no &lt;code&gt;deletionPolicy&lt;/code&gt;: orphaning means &lt;code&gt;managementPolicies&lt;/code&gt; without &lt;code&gt;Delete&lt;/code&gt;.)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Env isolation lives in the provider layer, not in good intentions
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One provider identity per cluster&lt;/strong&gt;, bound through Workload Identity, holding admin only over the resource types the compositions create, IAM-member management on the project, and custom roles scoped down where a predefined role grants more than needed (for example, managing secret containers without reading secret versions). Nothing editor-level, anywhere.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cross-project creation through an enum of ProviderConfigs&lt;/strong&gt; on layer-1 kinds. The prod-targeting ProviderConfig &lt;strong&gt;exists only on prod-tier clusters&lt;/strong&gt;. On a non-prod cluster the value passes the enum but resolves to nothing, so the XR never provisions. &lt;strong&gt;Non-prod cannot reach prod through self-service, by construction.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A layer-2 grant XR references its target&lt;/strong&gt; through a &lt;code&gt;*Ref&lt;/code&gt; to a layer-1 XR and &lt;strong&gt;inherits that XR's provider&lt;/strong&gt;. It never re-declares a project. To grant on a bucket owned elsewhere, you first declare an &lt;code&gt;ObserveOnly&lt;/code&gt; &lt;code&gt;Bucket&lt;/code&gt; for it and reference that. Every resource has exactly one owner, and everyone else observes it.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4. Identity-only service accounts, no default roles
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;Identity&lt;/code&gt; XR creates the cloud service account, the Workload Identity binding and the Kubernetes service account, and nothing else. Workload Identity already &lt;em&gt;is&lt;/em&gt; the credential, so a workload needs no baseline role just to "be" its identity. Log or metric writer roles only if the workload ships through the cloud's own services; token creation only if the code signs URLs or mints ID tokens.&lt;/p&gt;

&lt;p&gt;Defaults can be moved out safely in three separately merged phases:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Preserve:&lt;/strong&gt; add explicit grant XRs re-granting exactly what the defaults gave.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deprecate:&lt;/strong&gt; stop the composition emitting them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim:&lt;/strong&gt; grep the code for each capability and drop what nothing uses.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This cuts over without any flapping, because &lt;code&gt;ProjectIAMMember&lt;/code&gt; is keyed by &lt;code&gt;project/role/member&lt;/code&gt;: the old and new MRs point at the same binding.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Green in ArgoCD means correct
&lt;/h3&gt;

&lt;p&gt;ArgoCD can show an XR green while the composite behind it fails. Have shared helpers record each cloud resource in the XR's status with a &lt;code&gt;verified&lt;/code&gt; flag (observed, right name, intended project) and roll them up into &lt;code&gt;allVerified&lt;/code&gt;. Then write one custom health check for the whole XR API group that follows the same convention: reconcile error → &lt;code&gt;Degraded&lt;/code&gt;, paused → &lt;code&gt;Suspended&lt;/code&gt;, &lt;code&gt;Ready&lt;/code&gt; &lt;em&gt;and&lt;/em&gt; &lt;code&gt;allVerified&lt;/code&gt; → &lt;code&gt;Healthy&lt;/code&gt;, else &lt;code&gt;Progressing&lt;/code&gt;, with every &lt;code&gt;False&lt;/code&gt; condition in the message. Green means confirmed in the cloud; otherwise the developer sees why.&lt;/p&gt;

&lt;h2&gt;
  
  
  The same black box for alerts
&lt;/h2&gt;

&lt;p&gt;The pattern also fits platform-owned resources. An &lt;code&gt;Alert&lt;/code&gt; kind can be one Grafana alert rule group whose rules may own the log-based counter metric they threshold. Its schema holds the rules that are easy to break by hand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Derived identity.&lt;/strong&gt; A rule's uid is &lt;code&gt;&amp;lt;xr&amp;gt;-&amp;lt;rule&amp;gt;-&amp;lt;env&amp;gt;&lt;/code&gt;, since a silence binds to the uid; a new uid detaches every silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Names that never change.&lt;/strong&gt; Log-based metrics aren't retroactive: a rename restarts the counter at zero.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Loud failure.&lt;/strong&gt; A query error maps to &lt;code&gt;Error&lt;/code&gt;, not &lt;code&gt;OK&lt;/code&gt;, so a metric never created (missing IAM) can't pass as "no events".&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One XR for every env.&lt;/strong&gt; The filter takes a &lt;code&gt;{project}&lt;/code&gt; placeholder; overlays patch one field.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Evolving XRDs and compositions
&lt;/h2&gt;

&lt;p&gt;Removing or changing a field in a served XRD version goes against Crossplane's own guidance: a version's schema can't change existing fields, and breaking changes belong in a new XRD. What the docs don't describe well is the actual path: how to move live XRs, their composed resources and their connection secrets from the old shape to the new one.&lt;/p&gt;

&lt;p&gt;What worked for me needed no separate clean cluster. Every XRD or composition change went through a prod-like environment first, with its cluster pointed at the branch, and to prod only after it reconciled cleanly there. It works, but isn't fully automated: promotion and the checks between steps are manual.&lt;/p&gt;

&lt;h2&gt;
  
  
  Letting a coding agent generate the right resources
&lt;/h2&gt;

&lt;p&gt;There is no magic "intent to YAML" skill. What works is a &lt;strong&gt;docs chain&lt;/strong&gt; that a coding agent follows the same way a new engineer would:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The repo's agent instructions&lt;/strong&gt; say: v2 claimless XRs only, never raw managed resources, and point to one file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A single authoritative self-service guide&lt;/strong&gt; next to the XRDs: per kind, the required spec, a copy-paste example, output secret keys and gotchas. Crucially, it maps &lt;em&gt;use case to field&lt;/em&gt; ("role → when you need it"), so "my worker reads the uploads bucket" resolves to a &lt;code&gt;BucketGrant&lt;/code&gt; with a viewer role, not a guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A commented scaffold&lt;/strong&gt;, so the agent copies structure rather than inventing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;XRD &lt;code&gt;description:&lt;/code&gt; fields&lt;/strong&gt; written for the reader, so &lt;code&gt;kubectl explain&lt;/code&gt; doubles as documentation.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Then the important part: &lt;strong&gt;validation that does not trust the agent.&lt;/strong&gt; &lt;code&gt;kustomize build&lt;/code&gt; passes an out-of-enum role happily, because it doesn't know the XRD schema. Only a server-side diff (&lt;code&gt;kustomize build … | kubectl diff --server-side&lt;/code&gt;) or the ArgoCD sync validates against the live structural schema. That is the mandatory pre-merge gate for any manifest touching an XRD.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pros / cons
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Developer PR = cloud change. No ticket, no static keys&lt;/td&gt;
&lt;td&gt;Two to five minute convergence for multi-reconcile kinds (&lt;code&gt;DatabaseGrant&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Controls enforced at admission, not in review&lt;/td&gt;
&lt;td&gt;XRD schema evolution is hard: removing a field means migrating every XR&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One owner per resource, one place to look&lt;/td&gt;
&lt;td&gt;Platform team owns a KCL codebase of compositions and their tests&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Env isolation structural (provider absent off prod-tier)&lt;/td&gt;
&lt;td&gt;Some principals (groups, external identities) still need Terraform&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;When not to do this:&lt;/strong&gt; if you have fewer than a handful of teams, or your cloud footprint changes rarely, Terraform modules with CODEOWNERS are cheaper. The black boxes earn their keep when request volume is high and the set of allowed shapes is small.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas I actually hit
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A &lt;code&gt;*Ref&lt;/code&gt; never crosses clusters.&lt;/strong&gt; The reference is resolved by observing an object on the &lt;em&gt;local&lt;/em&gt; API server. Point it at an XR that lives on another cluster and you get &lt;code&gt;Synced=True&lt;/code&gt;, &lt;code&gt;Ready=False&lt;/code&gt;, and &lt;strong&gt;nothing emitted&lt;/strong&gt;: the cloud is never called. Check which cluster an overlay actually deploys to, not what the overlay is named.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Synced=True&lt;/code&gt; is not "the cloud has it".&lt;/strong&gt; It only says the last reconcile succeeded; readiness is what &lt;code&gt;Ready&lt;/code&gt; reports. Even with everything green after a merge, the cloud can still hold the old config until one more provider reconcile (about 2 minutes observed). The verification of record is a &lt;code&gt;gcloud … describe&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sync waves: put all XRs in one negative wave&lt;/strong&gt;, and leave the workload on the default wave 0. A chain of negative waves deadlocked a first install (an existing namespace can't show it): grants waited on an identity whose wave hadn't run. Compositions already sequence XRs among themselves (a grant waits for its identity), so waves add nothing. Only the app that &lt;em&gt;defines&lt;/em&gt; the XRDs must do the opposite (XRs after definitions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remove XRs before their XRD, in two syncs.&lt;/strong&gt; A foreground prune deletes the XRD's RBAC roles first, so Crossplane can't delete its own XR and the XRD sticks in &lt;code&gt;Terminating&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adopting an existing resource writes your spec onto it.&lt;/strong&gt; Replicate the live config (location, lifecycle, CORS) before adopting, or "adoption" becomes a mutation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] AppProject whitelists your XR group, not provider groups.&lt;/li&gt;
&lt;li&gt;[ ] Every role/location/provider choice is an &lt;code&gt;enum&lt;/code&gt;, read-only roles first. No free-text principals.&lt;/li&gt;
&lt;li&gt;[ ] Prod-targeting ProviderConfig exists only on prod-tier clusters.&lt;/li&gt;
&lt;li&gt;[ ] Data resources orphan on delete. Grants revoke on delete. Review that field.&lt;/li&gt;
&lt;li&gt;[ ] Layer 1 never manages IAM. Layer 2 is additive only.&lt;/li&gt;
&lt;li&gt;[ ] One authoritative guide, with a use-case-to-field table, linked from the agent instructions.&lt;/li&gt;
&lt;li&gt;[ ] Server-side diff as the pre-merge gate. &lt;code&gt;kustomize build&lt;/code&gt; is not schema validation.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Crossplane composite resource definitions: &lt;a href="https://docs.crossplane.io/latest/composition/composite-resource-definitions/" rel="noopener noreferrer"&gt;https://docs.crossplane.io/latest/composition/composite-resource-definitions/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Crossplane composition revisions: &lt;a href="https://docs.crossplane.io/latest/composition/composition-revisions/" rel="noopener noreferrer"&gt;https://docs.crossplane.io/latest/composition/composition-revisions/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;ArgoCD projects (resource whitelists): &lt;a href="https://argo-cd.readthedocs.io/en/stable/user-guide/projects/" rel="noopener noreferrer"&gt;https://argo-cd.readthedocs.io/en/stable/user-guide/projects/&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;GKE Workload Identity Federation: &lt;a href="https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity" rel="noopener noreferrer"&gt;https://cloud.google.com/kubernetes-engine/docs/concepts/workload-identity&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I wrote this article with the help of Claude, an AI assistant. I reviewed and edited it by hand, and it is based on my own hands-on experience.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>devops</category>
      <category>kubernetes</category>
      <category>crossplane</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>Running 15 Claude Code Sessions at Once</title>
      <dc:creator>Ilya Gorban</dc:creator>
      <pubDate>Thu, 08 Oct 2026 20:15:21 +0000</pubDate>
      <link>https://dev.to/ilya_gorban/running-15-claude-code-sessions-at-once-5bec</link>
      <guid>https://dev.to/ilya_gorban/running-15-claude-code-sessions-at-once-5bec</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; Ten or more parallel Claude Code sessions work if you treat them as a small distributed system: ownership by function, one writer (a governance session) for shared rules and hooks, approval never relayed, repeat mistakes turned into guard hooks. Assumptions are verified before a human is asked, and no Claude-generated edit ships unreviewed, with GitOps as the record.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is an opinionated write-up of what worked for me as a solo DevOps engineer, in one company's setup. Treat it as experience to weigh, not as the one right answer.&lt;/em&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Why: what goes wrong with an "octopus"
&lt;/h2&gt;

&lt;p&gt;I run 10–15 sessions at once in cmux, one per task. I'm the only DevOps engineer, so all are mine. Failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;File collisions.&lt;/strong&gt; Two sessions edit one rule file at once. &lt;code&gt;git add -A&lt;/code&gt; swept another session's edits into the wrong commit; staging explicit paths doesn't help, since a plain &lt;code&gt;git commit&lt;/code&gt; takes the whole index.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Relayed "approvals".&lt;/strong&gt; A tells B "the user approved this"; B can't verify it, A can't transfer it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Worktree sprawl, stale state.&lt;/strong&gt; About 18 worktrees across two repos; a sweep couldn't tell "abandoned" from "mid-task". A peer's "it's merged" can precede the merge.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context loss on compaction.&lt;/strong&gt; A brief lives only in the sender's transcript, so it vanishes when both sides compact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hook fixes racing.&lt;/strong&gt; One session's regex fix undid another's; the suite stayed green, each fix tested only on its own case.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What: the setup
&lt;/h2&gt;



&lt;pre data-lang="mermaid"&gt;&lt;code&gt;flowchart LR
  subgraph cmux["cmux window"]
    S1["Session: task A&amp;lt;br/&amp;gt;worktree A"]
    S2["Session: task B&amp;lt;br/&amp;gt;worktree B"]
    S3["Session: task C&amp;lt;br/&amp;gt;worktree C"]
    G["Governance delegate&amp;lt;br/&amp;gt;(owns global rules + hooks)"]
  end
  S1 -- "SendMessage: brief" --&amp;gt; G
  S2 -- "SendMessage: blocked by guard X" --&amp;gt; G
  G -- "result + 'you can close your side'" --&amp;gt; S1
  S3 &amp;lt;-- "collision notice" --&amp;gt; S2
  U(("Human")) -. "approves in EACH session" .-&amp;gt; S1 &amp;amp; S2 &amp;amp; S3 &amp;amp; G&lt;/code&gt;&lt;/pre&gt;



&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Piece&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;cmux workspaces&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one per session; worktrees named &lt;code&gt;&amp;lt;session&amp;gt;--&amp;lt;desc&amp;gt;&lt;/code&gt;, so &lt;code&gt;git worktree list&lt;/code&gt; shows owners.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;One session = one task = one worktree/branch/PR&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;launch at the parent directory of all repos, never inside a worktree: it breaks on removal and can't switch repos.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Local task list, &lt;code&gt;T&amp;lt;n&amp;gt;&lt;/code&gt; ids&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;created &lt;em&gt;before&lt;/em&gt; investigating. &lt;code&gt;T&amp;lt;n&amp;gt;&lt;/code&gt;, never &lt;code&gt;#&amp;lt;n&amp;gt;&lt;/code&gt; (a GitHub reference; a hook enforces it).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Governance delegate&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;one long-lived session owns all shared-config writes, found by &lt;strong&gt;role&lt;/strong&gt;, never by name.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Single owners&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;hook repair → the delegate, wherever the hook fired; machine-wide worktree removal → one sweep owner; a repo's &lt;code&gt;.claude/&lt;/code&gt;, PRs and tickets → the session working it. SendMessage crosses ownership boundaries; it doesn't offload your own work.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;From about 57 real cross-session messages: &lt;strong&gt;finding a defect doesn't make it yours to fix, and being handed a fix doesn't make it yours to approve.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  How
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. One dedicated session owns the global rules and hooks
&lt;/h3&gt;

&lt;p&gt;The global &lt;code&gt;CLAUDE.md&lt;/code&gt;, pattern docs, hooks, tests and settings are shared; two writers on one rule is a silent race. So one &lt;strong&gt;dedicated&lt;/strong&gt; session writes there and takes no feature work. Others send it a brief for any wrong rule, hook defect or missing guard, create a local task, and never edit inline.&lt;/p&gt;

&lt;p&gt;The boundary is mechanical: global config is one directory symlinked into &lt;code&gt;~/.claude/&lt;/code&gt;, so &lt;code&gt;readlink -f&lt;/code&gt; tells you (plan files exempt).&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Delegation moves the work, never the approval
&lt;/h3&gt;

&lt;p&gt;Every brief says: &lt;em&gt;"Nothing below is pre-approved; route every approval through the user in your own session."&lt;/em&gt; A harness timeout isn't approval. A peer's word counts when it &lt;em&gt;restricts&lt;/em&gt; you ("these worktrees are still mine"), never when it &lt;em&gt;widens&lt;/em&gt; what you may do.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. A brief is a hypothesis, and the receiver files its own ticket
&lt;/h3&gt;

&lt;p&gt;A good brief states: kind (request, finding, FYI); approval status; evidence (exact command or file:line); what was ruled out; what is deliberately &lt;em&gt;not&lt;/em&gt; proposed; constraints the receiver can't see; disk state.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;receiver&lt;/strong&gt; creates the local task and ticket, and the ticket id goes &lt;em&gt;inside&lt;/em&gt; the brief; a message is not a tracking system. The receiver verifies conclusion and &lt;em&gt;mechanism&lt;/em&gt; separately: a peer once rightly called a worktree safe to remove, for a reason that was impossible.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Many sessions on one repo: each knows its co-workers by name
&lt;/h3&gt;

&lt;p&gt;Several sessions on one repo is fine, each on its own worktree, if each gets, at start, the list of its peers &lt;strong&gt;by name&lt;/strong&gt; and what each owns. Then a session that finds work belonging to a peer's piece delegates it there instead of duplicating it, and interconnected changes land in the right order. Before touching a shared path, announce your workstream, ticket, the exact meeting path, and the peer's likely surprise.&lt;/p&gt;

&lt;h3&gt;
  
  
  5. Order the delegate's queue
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Hooks, then rules, then other substantive work, worktree cleanup last.&lt;/strong&gt; A broken hook hits every session; a broken rule only its readers.&lt;/p&gt;

&lt;h3&gt;
  
  
  6. When a guard blocks you with no legitimate way through
&lt;/h3&gt;

&lt;p&gt;Send the delegate the command, the guard, its exact message, and what you tried. Then stop: "blocked" is a complete answer. Repackaging the command into a script, reading the refusal's hint as permission, and narrowing the guard yourself are ruled out.&lt;/p&gt;

&lt;h3&gt;
  
  
  7. No Claude-generated edit goes unreviewed by a human
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Nothing a session writes reaches a shared branch, cluster or cloud project until a human has read it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sessions stop at "ready for review".&lt;/strong&gt; They open the PR; I read the diff and merge. A session never merges, even if its own option said so.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No hand changes to live systems.&lt;/strong&gt; Mutating &lt;code&gt;kubectl&lt;/code&gt;, console edits and Terraform applies are blocked or handed to me as a command; clusters change only via a reviewed commit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GitOps is the second check.&lt;/strong&gt; Git records every change; ArgoCD shows drift from git. To check what slipped past review, I compare the two, not a session's summary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cost: review is the tiring bottleneck; small PRs and summaries of what was &lt;em&gt;tested&lt;/em&gt;, not &lt;em&gt;edited&lt;/em&gt;, help. A session 95% right ships its 5% wrong unnoticed.&lt;/p&gt;

&lt;h3&gt;
  
  
  8. Verify before you ask: a non-negotiable global rule
&lt;/h3&gt;

&lt;p&gt;Asking about a flag that doesn't exist turns approval into fact-checking. So the global "verify, don't claim" rule makes &lt;strong&gt;asking the gate&lt;/strong&gt;: verify every load-bearing assumption before putting a change or an option list to me, attach the evidence, and mark anything unsettled as unverified. Three sources, strongest first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The real environment.&lt;/strong&gt; Read-only commands (&lt;code&gt;describe&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;--dry-run=server&lt;/code&gt;, a live metrics or logs query); a live counter-example beats any document.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authoritative documentation or source, at the version actually deployed.&lt;/strong&gt; Docs via a lookup tool, not memory; upstream source at the release tag you run, not &lt;code&gt;main&lt;/code&gt;; &lt;code&gt;--help&lt;/code&gt; on the exact subcommand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Online research&lt;/strong&gt;, as corroboration when the first two are silent.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A &lt;strong&gt;proposal&lt;/strong&gt; claims a blast radius; a &lt;strong&gt;conclusion&lt;/strong&gt; like "impossible" needs a search for a working instance. A silent authoritative source is my decision, never a gap to guess.&lt;/p&gt;

&lt;p&gt;A hook enforces the query part. The version clause came from fact-checking this series: a checker proposed a migration flag that upstream &lt;code&gt;main&lt;/code&gt; has but the target release lacks, and that isn't equivalent to the documented one. Only per-tag source showed that.&lt;/p&gt;

&lt;p&gt;The payoff: fewer, evidence-backed questions and fewer false results in PRs and docs.&lt;/p&gt;

&lt;h3&gt;
  
  
  9. Sessions have priorities: visit the urgent ones more often
&lt;/h3&gt;

&lt;p&gt;An incident outranks a doc tidy-up. A waiting session is stalled, so a rarely visited one runs at my response rate. I visit urgent sessions far more often and answer questions as they arrive.&lt;/p&gt;

&lt;p&gt;Having ADHD really helps here: jumping between many short threads is how my attention works anyway. Every session also writes for it: action first, open questions restated in full, one next step.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rule layering: where knowledge goes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Holds&lt;/th&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Global &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Short non-negotiables pointing to detail docs&lt;/td&gt;
&lt;td&gt;Loaded every session; characters cost tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Global pattern/workflow/troubleshooting docs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Examples, command lists&lt;/td&gt;
&lt;td&gt;Default home for anything over one line&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;In-repo &lt;code&gt;.claude/&lt;/code&gt; + &lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Team-visible conventions&lt;/td&gt;
&lt;td&gt;Would a teammate's agent act wrongly without it?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Per-user memory&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Facts about &lt;em&gt;this human&lt;/em&gt;
&lt;/td&gt;
&lt;td&gt;Agent-behaviour lessons are &lt;strong&gt;not&lt;/strong&gt; personal&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Copy, never point.&lt;/strong&gt; A team repo never references a personal KB file teammates lack; copy the content (a hook rejects personal-KB paths).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;If you broke an existing rule, extend its doc, never memory&lt;/strong&gt;: a silent rule lets the next session repeat the breach.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Guard hooks as enforcement
&lt;/h2&gt;

&lt;p&gt;About 49 hook files, 57 test files:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Class&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Block mutating &lt;code&gt;kubectl&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;reads, &lt;code&gt;diff&lt;/code&gt;, &lt;code&gt;--dry-run=server&lt;/code&gt;, ArgoCD sync patches allowed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Block commits/edits on &lt;code&gt;main&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;team repos need a worktree; personal exempt.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Require full PR URLs&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;a bare &lt;code&gt;#123&lt;/code&gt; can't be opened from a terminal.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Prevent scratch files in &lt;code&gt;/tmp&lt;/code&gt;&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;it vanishes mid-session.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Require query verification&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;denies PromQL/LogQL/SQL in a doc unless that exact query ran live this session: a documented join had never matched a row.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Stop-time push check&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;blocks ending a turn with unpushed commits or a PR-less branch, in every worktree the session itself edited or committed to (checking only the current repo once missed one).&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The enforcement ladder, weakest first:&lt;/strong&gt; prose in a doc → a hook that blocks the call → &lt;strong&gt;removing the option entirely&lt;/strong&gt;. A bare-name &lt;code&gt;permissions.deny&lt;/code&gt; entry removes a whole tool from Claude's context; a scoped rule like &lt;code&gt;Bash(rm *)&lt;/code&gt; only blocks matching calls.&lt;/p&gt;

&lt;p&gt;Use &lt;code&gt;PreToolUse&lt;/code&gt; (exit 2 blocks; &lt;code&gt;PostToolUse&lt;/code&gt; cannot): a post-edit CLAUDE.md size warning only watched it grow; denying the projected size in &lt;code&gt;PreToolUse&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Hooks are code: test-first, every time
&lt;/h3&gt;

&lt;p&gt;A broken hook blocks work everywhere or silently stops guarding, so hooks get strict TDD:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Write the failing test first.&lt;/strong&gt; Every hook has a suite in &lt;code&gt;hooktests/&lt;/code&gt;; each new case (an input to deny, a near-miss to allow) goes in first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove it goes RED.&lt;/strong&gt; Run the new cases against a copy of the hook from &lt;em&gt;before&lt;/em&gt; the change; a test passing against the old hook may not exercise the branch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make it GREEN, then run the whole suite&lt;/strong&gt;. One runner executes every suite, a syntax sweep, and a check that every registered hook path resolves (a parsing hook can be dead if unregistered); per-fix spot checks caused the racing failure.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A hook change without a test change is denied&lt;/strong&gt; by a commit guard, so time pressure can't skip it.&lt;/li&gt;
&lt;/ol&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Fail open&lt;/strong&gt; on unparseable input or a missing &lt;code&gt;jq&lt;/code&gt;; an over-broad Bash guard also removes file search. The exception is a guard on an irreversible action (see the update below).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Team rules (in-repo)
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Branches &lt;code&gt;&amp;lt;ticket&amp;gt;/&amp;lt;type&amp;gt;/&amp;lt;desc&amp;gt;&lt;/code&gt;, never commit to main, Conventional Commits &lt;strong&gt;without scopes&lt;/strong&gt; (on squash-merge repos the PR title's type is the release bump).&lt;/li&gt;
&lt;li&gt;Each repo has a KB index (&lt;code&gt;.claude/README.md&lt;/code&gt;) and its own hooks in &lt;code&gt;.claude/settings.json&lt;/code&gt;, so the team runs them too. Quote &lt;code&gt;SKILL.md&lt;/code&gt; descriptions: unquoted ones hid 9 of 14 skills from a stricter-YAML agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Cost: model routing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Opus primary&lt;/strong&gt; plans, synthesises and reviews; cheaper &lt;code&gt;worker&lt;/code&gt; subagents execute in parallel. &lt;strong&gt;Routine work: Sonnet primary&lt;/strong&gt;, with an Opus &lt;code&gt;oracle&lt;/code&gt; for hard decisions. Never downgrade reasoning on risky or irreversible decisions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context isolation saves the most:&lt;/strong&gt; one misconfigured autocompact threshold (a 1M-context session compacting only at about 800k tokens) outweighed everything else.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pros / cons
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Pros&lt;/th&gt;
&lt;th&gt;Cons&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Real parallelism, full context each&lt;/td&gt;
&lt;td&gt;Coordination overhead: briefs, notices, a queue&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;One writer removes write races&lt;/td&gt;
&lt;td&gt;Delegate is a bottleneck; you still approve everything&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hooks catch repeat mistakes everywhere&lt;/td&gt;
&lt;td&gt;Hooks need tests; a bad hook blocks every session&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Review plus GitOps catches what guards miss&lt;/td&gt;
&lt;td&gt;Reviewing every PR is the real, tiring bottleneck&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;When not to do this:&lt;/strong&gt; one or two sessions, or no shared global config.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gotchas
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;After removing a worktree, re-list and tell the owning session, or its next command fails.&lt;/li&gt;
&lt;li&gt;A session's readable name needs a cmux call; the environment holds only UUIDs.&lt;/li&gt;
&lt;li&gt;cmux's default next/previous-workspace shortcut crosses groups; since 0.64.23, bind &lt;strong&gt;Next/Previous Workspace in Group&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;One dedicated session writes global rules and hooks; approval is never relayed; the receiver files the ticket.&lt;/li&gt;
&lt;li&gt;Copy team knowledge into the repo; memory holds only facts about the human.&lt;/li&gt;
&lt;li&gt;Climb the ladder: doc, &lt;code&gt;PreToolUse&lt;/code&gt; deny, remove the option; hooks are test-first with a proven RED.&lt;/li&gt;
&lt;li&gt;A human reviews every change; git plus ArgoCD's diff shows nothing slipped past.&lt;/li&gt;
&lt;li&gt;Verify before asking: live environment, docs or source, then the web.&lt;/li&gt;
&lt;li&gt;Name each session's co-workers; visit urgent sessions often.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Claude Code hooks reference (exit codes): &lt;a href="https://code.claude.com/docs/en/hooks" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/hooks&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Claude Code permissions (&lt;code&gt;permissions.deny&lt;/code&gt;): &lt;a href="https://code.claude.com/docs/en/permissions" rel="noopener noreferrer"&gt;https://code.claude.com/docs/en/permissions&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;cmux changelog (0.64.23): &lt;a href="https://github.com/manaflow-ai/cmux/blob/main/CHANGELOG.md" rel="noopener noreferrer"&gt;https://github.com/manaflow-ai/cmux/blob/main/CHANGELOG.md&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Update, 12 October 2026: what changed after publishing
&lt;/h2&gt;

&lt;p&gt;Readers' comments on this article led straight to fixes. Thank you to everyone who took the time to write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prompted by readers:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;I replayed my real commands against the guards.&lt;/strong&gt; One reader measured which risky commands their agents actually ran, instead of the ones they feared. I did the same: about 30,000 shell commands from my sessions held 161 destructive git commands, and none of my guards blocked any of them. Most named explicit files, a session throwing away its own edit. Seven were &lt;code&gt;checkout&lt;/code&gt; or &lt;code&gt;restore&lt;/code&gt; with no real path (&lt;code&gt;git checkout -- .&lt;/code&gt;, &lt;code&gt;git restore .&lt;/code&gt;), which in a shared checkout also drops every other session's uncommitted work, with no undo. A new guard blocks only that form; explicit paths stay allowed. Covering all 161 would have fired constantly and been muted within a day.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Guards now count their own runs and blocks.&lt;/strong&gt; Another reader keeps per-lesson counters to see whether a lesson holds. I adopted the run-and-block half: every guard logs each run and each block, so whether it still earns its place is decided from counts, not impressions. I did not adopt retiring a guard after a quiet stretch. A quiet guard may simply be working.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Have you retired a guard yet?"&lt;/strong&gt; Once. A nudge hook fired on 20% of 3,583 replayed commands, so it was retired for being wrong too often, not for being quiet. Its own tests had all passed, because every case was one where it should fire.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Other corrections since publishing:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Guards on irreversible actions now fail closed.&lt;/strong&gt; Claude Code added &lt;code&gt;onFailure: "block"&lt;/code&gt; to hook registrations, so a guard that can't run, times out or crashes denies the action instead of letting it through. I set it only where a miss can't be undone: mutating &lt;code&gt;kubectl&lt;/code&gt;, Terraform applies, secret access, mutating SQL, and pointing prod at a branch. Never on Edit or Write: a failing guard there would block its own repair. Everything else still fails open, because stopping every session over a recoverable mistake is the wrong trade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The stop-time push check now covers only worktrees the session itself changed.&lt;/strong&gt; It used to check any branch mentioned in the transcript, which flagged other sessions' work.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;I wrote this article with the help of Claude, an AI assistant. I reviewed and edited it by hand, and it is based on my own hands-on experience.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>claudecode</category>
      <category>ai</category>
      <category>devops</category>
      <category>productivity</category>
    </item>
  </channel>
</rss>
