control-plane-project-charter

Shared rules and per-language references for the Upbound control-plane-project skills - agent behaviour, the test-first loop, what a v2 composed resource needs, the container boundary, what a green ru

SKILL.md

GitHub

Development Charter

Every skill in this plugin follows this charter: how to behave as an agent, how to develop, what Crossplane v2 requires, and how to report what you did.

It does not replace the task skill. Before you write or review, load what your row names:

Work Load And read
reviewing no task skill open references/charter/review.md now: the review checks are there, not in this file. Then the language file's failure modes for what changed
XRD, upbound.yaml, dependencies, examples, ProviderConfig, MRAP author-configuration-package author-configuration-package/references/mrap.md for an MRAP
function code author-composition the language file (§10), e.g. references/languages/go.md (models, imports) and references/languages/go/functions.md
anything under tests/ author-tests the test language's file, e.g. references/languages/go/tests.md; an E2ETest: author-tests/references/e2e.md
running E2E tests, re-runs included e2e-test-configuration e2e-test-configuration/references/local.md or space.md beside it

A path starting references/ is in this skill's directory; one starting with a skill's name is in that skill's directory, beside this one.

This file holds the rules, none of them language-specific and none optional. The evidence and worked examples sit in charter/, the syntax in languages/ (§10). Where a skill contradicts this charter, the charter wins, and the skill has a bug.

Facts these skills mark as observed or measured with up v0.55.0, or checked against its source, were checked. If up version --client (there is no --version flag) prints v0.55.0, use them as stated: re-probe one only when your own run contradicts it, and report the contradiction.


1. Know which kind of agent you are

How you were started decides whether you can ask anything; every skill's mode line means this:

  • Interactive — loaded into the user's conversation (inline): ask only what the project can't tell you.
  • Unattended — handed a brief as a separate agent (forked), or no user in the loop: never ask; decide from the spec and state the assumption, or stop and report.

When nobody can answer, never block on a question — that includes a caller that cannot relay one. Wherever a skill says to ask, read it with this rule.

Detail: charter/agent-context.md has what each context may and may not do, why a fork's only output channel is prose, and how to hand work to a sub-agent or run a long command when your harness cannot: in band and in the foreground, never detached by you.

2. Discover, do not interview

The project's own decisions win over these skills' defaults. When the project itself decides something differently — its XRD/API, a design document, its gate script or Makefile, or the user — a providerConfigRef the platform needs, a gate script in place of a skill's verification steps, local E2E only — follow it, and say in your report where you departed from a skill's default and why.

What you need Where it is
Project root args, or the nearest ancestor containing upbound.yaml
XR kind, group, version, spec schema apis/*/definition.yaml
Existing composition + functionRef apis/*/composition.yaml
Available provider families upbound.yaml spec.dependsOn
Example XR shape the file up example generate writes: examples/<kind-lowercase>/<xr-name>.yaml, the name defaulting to the lowercase Kind (examples/network/network.yaml, up v0.55.0). Find it; don't assume example.yaml
Composition language functions/*/ contents — see languages/
Test language the tests/*/ dirs that produce a CompositionTest or E2ETest — may differ from the composition language; with none yet, §10 picks it
Provider field names, types and constraints the generated models under .up/
Current Space / group / control plane up ctx . --short — how to read it: charter/targets.md

Read the XRD's defaults before you design anything against it. up test run applies them to the XR before the pipeline sees it (§8 has the limits), so a field with a default: reaches your function whether the example sets it or not, and a test that asserts only what the example mentions is asserting the XRD's defaults back to itself.

3. Develop test-first (RED → GREEN → REFACTOR)

The default flow for composition code, tests and migrations; author-tests owns the authoring detail.

Test-first is for behaviour: the function, the composition, how status is derived. Scaffolding, upbound.yaml metadata, dependencies, the XRD, an MRAP and the examples are declarative: up project build, author-configuration-package's check_xrd_schema.py and the composition tests that use them check those. Write no test program for them, and don't copy a skill's script into the project.

Why: watching a test fail first makes a coverage claim checkable.

The loop

  1. RED — write the test, run it, and read the failure.

    up test run "tests/<t>"      # expect: FAIL
    

    Run it directly, not through verify-configuration, which builds the package and runs the whole suite.

    A failure is only RED if it fails for the reason you intended. Check the message:

    Failure Verdict
    no actual resource found for the resource you are about to compose, or for one whose annotation you are adding valid RED
    a field mismatch naming the exact field you are adding valid RED
    a syntax error, an unresolved import, a missing compositionPath, a run that stops at ✗ Parsing tests, a bug in the test's own logic not RED, even though it exits 1 — the test is broken, not the code. Fix it before writing any implementation
    a compiled language (Go): undefined: <symbol you are adding> not RED — a compile error. Add a stub that only returns the zero value, then watch the assertion fail; a stub that already does the work never shows RED
    passes immediately not RED — the assertion is vacuous, or the behaviour already exists
    E2E: an implementation mutation (drop a composed resource others depend on, break a selector) that never readies within a short timeoutSeconds valid RED, and optional (below). Editing defaultConditions or an expected value is not; an unparsable condition is a broken test (author-tests' e2e.md reference)

    Filling in a generated name or the rendered resourceRefs order after GREEN completes an expectation whose RED you already saw. It is not editing an expected value (charter/evidence.md).

    Record the failure text: your report cites it as evidence that the test bites.

    RED is required for composition tests and function unit tests, and optional for a new E2ETest. An e2e RED costs a real control-plane run: take it when it is cheap, as in the table's last row; otherwise report the E2ETest as unproven (§4).

  2. GREEN — implement until the test passes, and no further.

  3. REFACTOR with the suite green, then add the next failing assertion and repeat.

Use distinguishing inputs. Every value the function passes through gets, in at least one test, an input that is neither its default nor shared with a sibling field, or a hard-coded constant stays green (author-tests Phase 3). For a pass-through field, expected == input is correct; prove it by hard-coding the field in the function and seeing a test go red, never by editing the expectation.

Detail: charter/tdd.md has the two-tier inner loop (every up test run pays a full build, so use a fast tier for crashes), and how to backfill tests for code that already works by proving each one can fail: mutate the implementation, never the test's expected value.

4. Report the effect, not the intent

An overstated report asserts what the change was meant to do instead of reading back what it did.

Before writing any summary:

  1. Name what you ran, and its exit code — the command's own. After up test run … | tail -20, $? is tail's, not up's. Redirect, then read $? (up test run "tests/test-*" > /tmp/t.log 2>&1; echo "exit=$?"), or, after a pipe, read ${PIPESTATUS[0]} in bash or $pipestatus[1] in zsh, straight after it.
  2. Re-read the evidence you are about to paste and check it does not contradict your verdict.
  3. Distinguish the layers. A render is not an install; an install is not a provider accepting the resource; Ready=True is not the provider holding the value you meant. Say which layer you reached.
  4. When you did not verify something, say so plainly. "Not verified at the provider" is a useful report. A checkmark you cannot support is not.
  5. A coverage claim is a claim about what would fail. Before writing "covers X", "complete", or "N/N", answer: what change to the code would make this go red? If you cannot name one, you have not covered X — you have written something that passes.
  6. Drop the checkmark register. ✅ Complete, PASS, and a tidy summary table read as verification whatever is behind them, which is what makes an overstated report persuasive. Write what ran, what it printed, and what remains unknown. If the honest summary is "tests pass; provider validity unchecked; not deployed", that is the summary: more useful to the caller than a confident one that is wrong. A skill's success criteria are checks for you, not a report format: do not tick them off in a report.
  7. Comments, docs and READMEs claim no more than a named test or run. They are read as reports.

5. Crossplane v2: what a composed resource actually needs

When the project's API exposes these fields — an XRD with managementPolicies or a providerConfigName parameter, as many platform configurations do — pass them to every managed resource (MR) from the first one you write, have the E2E test create the ProviderConfig the reference names (author-tests' e2e.md), and say so in your report. How a review judges these fields: charter/review.md.

These rules are for a v2 project. The XRDs' apiVersion says which one you have, not the template a project came from: up project init's cloud templates are v1 and its k8s-webapp template is v2 (§10). A v1 project keeps its v1 APIs and its Kinds; migrating it is separate work (plan-v2-migration).

API groups and versions

Compositions target the .m. provider API groups (.m. means modern, not "naMespaced": charter/v2-resources.md).

Aspect Use this Not this
Managed resource apiVersion <service>.<cloud>.m.upbound.io/v1beta1 (Upbound providers) or <...>.m.crossplane.io/v1beta1 (community) the same group without .m.
A new XRD apiextensions.crossplane.io/v2 + scope: Namespaced; Kind without an X prefix, no claimNames v1, cluster-scoped, claimNames
Composition stays apiextensions.crossplane.io/v1 there is no v2 Composition — do not bump it

Set forProvider, and stop

Crossplane v2 fills in the rest; "required v2 fields" the project did not ask for can break the resource.

Field Do you set it? (the default; the project may override) Why
metadata.namespace No A namespaced XR's namespace overwrites it; only a cluster-scoped XR's resources keep the one the function sets
managementPolicies No The CRD defaults it to ["*"]; never write that yourself. Set it only for another policy (orphan on delete) or when the project's API exposes it
deletionPolicy No A namespaced MR has no such field; orphan with managementPolicies
providerConfigRef Omit it if and only if ClusterProviderConfig/default exists and is the right one The CRD defaults it to ClusterProviderConfig/default
metadata.name Only for a stable external name Otherwise generated: deterministic in a render (safe to assert), not across re-creations
crossplane.io/composition-resource-name Never by hand It comes from the key you store the resource under
writeConnectionSecretToRef Yes, on every resource whose connection details the function reads Crossplane hands a composed resource's connection details to the function only from the Secret this names. Without it they arrive empty, and no render shows the difference

Never let a missing connection detail fall back to a value. They are empty until the provider has written that Secret, and always empty without the reference above, so a default ("", None) publishes a Secret with an empty password that looks like success. Compose the Secret only once every value it needs is present.

The table is about the composed resource's own metadata, not about objects inside forProvider. A Kubernetes object embedded in a managed resource — the manifest of a provider-kubernetes Object, for instance — is input to the provider, and nothing fills in its namespace. Set it explicitly, normally to the XR's namespace: without it a provider-kubernetes Object stays Synced=False with an empty namespace may not be set when a resource name is provided (v1.3.3), and composition tests that assert the same omission stay green.

Detail: charter/v2-resources.md has the verified behaviour behind each table row, what these CRD defaults do to a render (it differs by language), how connection details reach a function and how to catch a missing one, when a providerConfigRef is warranted, what a missing or wrong ProviderConfig looks like on a control plane, and the two greps that catch a hardcoded one.

The XRD

Write the XRD yourself rather than inferring it from an example with up xrd generate: an example carries values, never constraints, so an inferred schema loses every required:, default:, open-ended map and status field you meant to have. charter/v2-resources.md has the v2 skeleton and what inference does to the generated model.

Then design the schema, do not just transcribe fields; most of it is permanent from the first version that ships. charter/xrd-design.md has why, the rules, the CEL patterns, and the --dry-run=server loop that proves them.

6. The provider schema is a lower bound, not the constraint set

Namespaced APIs and forProvider-only are Crossplane correctness; they say nothing about whether the provider accepts the resource, and a composition test passes either way.

Check every Kind you compose against its provider. Read the conditional rules the generated models record in docstrings and field descriptions (Required if …, Conflicts with …): optional in the type means only that the CRD does not require it. Then check the cloud API's own rules (name formats, reserved prefixes, create-only fields), and treat one list element → one object as a decision you justify, not the default.

State the result in your summary: which Kinds you checked, what the schema required, and which API-level rule you could not confirm. "I checked and found nothing" is a valid result. Silently skipping is not.

Detail: charter/provider-schema.md has examples (an S3 lifecycle rule that renders and fails with MalformedXML), the measured hit rates, the one docstring that is always a false positive, and the rule classes only the cloud API docs hold.

7. The container boundary

up test run runs the test program of every directory it matches before any test, with or without --e2e (up v0.55.0). Go and go-templating test programs run locally and see your full environment (Go through os.Getenv, go-templating through Sprig's env); KCL and Python programs run in a container that sees only UP_* variables and no ~/.aws. Name every test input UP_*, and run the composition gate as up test run "tests/test-*": one e2e program that exits non-zero — on a missing input, say — fails a plain tests/* run at ✗ Parsing tests. no valid CompositionTests found means the matched dirs produced no CompositionTest (e.g. e2etest-* without --e2e): a wrong glob, not a failing test. An e2e program's output carries its credential Secret: run one by hand only with a dummy UP_* value, and never print or save it with real ones (author-tests' e2e.md).

Detail: charter/container.md has the per-language table, the UP_ credential route, the E2E command and the README note, and the tighter second boundary around function rendering.

8. A green run is not evidence

Green thing What it actually proves
up project build the package was assembled, never that the function runs. Only Go compiles here; what each language's build checks: charter/evidence.md
up test run printing No test files found, or an --e2e run printing Total Tests Executed: 0 nothing ran. No test was collected, and it exits 0 anyway — that is not a pass. --e2e over directories that produce no E2ETest (a test-* glob, or a program emitting only CompositionTests) builds the whole package, runs nothing and exits 0 (up v0.55.0). A test directory that emits no CompositionTest, such as a Go test program printing items: [], contributes zero tests; next to real tests it just drops out of the count. Report "no tests ran".
A composition test suite the assertions you wrote held against the render. assertResources is partial and positive for objects: it ignores composed resources it does not list, and within a resource it checks only the fields you name, at every depth. Lists are the exception — an asserted list must match the rendered one exactly in length and order, or you get lengths of slices don't match. A short list assertion is not a weak assertion; it is a failing one.
A render the function produced objects. Not that the API server accepts them, and not that the provider does.
Ready=True the provider reconciled something. Read back the field you meant, from the live object, not from the input manifest.

A generated test proves nothing until you fill it in. up test generate's stub has empty paths and fails before any assertion: a broken test, not RED (§3; charter/generators.md). With the paths filled in, an assertResources list you never populated is green and means nothing.

A filled-in single test is not much better: one input shape, asserting only composed resources, passes over a function that crashes on a minimal XR and drops three of four status fields.

Observed-state branches are reachable, and you are expected to reach them. A render starts with no observed resources unless the test supplies spec.observedResources. So code gated on observed-and-ready is dead in a test that omits that field and live in one that sets it — write the second test rather than declaring the branch untestable. For a namespaced XR, give each mock the XR's namespace and the render's name: without the namespace it is silently ignored (charter/evidence.md, Coverage). Such a test proves your branch logic given the status you wrote, not that a provider ever reports that status. For that, --e2e or a live apply — or say it is unverified.

The XRD defaulting in §2 has two limits:

  • Only if the test sets xrdPath (or inline xrd). Without it the XR is rendered exactly as written and no defaulting happens at all.
  • The XR only. Composed resources are never touched by this. Provider CRD defaults are a different mechanism — see §5.

Detail: charter/evidence.md has why an _output/composition_test/ directory your run didn't print is stale, how assertResources matches a resource, asserting spec.crossplane.resourceRefs so a surplus resource fails the suite, and what a suite must contain (minimal XR, observed-state branches, status on the composite).

9. Never create infrastructure as a side effect

A group, a Space, a control plane or a published package is the user's decision. Running what you were asked to run — an E2E test, up project run — is not a side effect; anything you add to make it work is.

  • Never create a group, Space or control plane to make a command work.
  • Never let the context choose the target silently. Read it with up ctx . --short, or pass --local; a local KIND result is not a Space result.
  • Never pass --public on your own initiative. It publishes the user's package — an irreversible disclosure, never a debugging step.
  • Never pass a --kubeconfig you did not write and check in this run.
  • Delete what this run created, and nothing else. Your own leftovers — a control plane, a kind cluster and its registry container, a scratch directory — must be removed before you report; for anything else, report what you would delete and let the user choose.
  • Delete a control plane only once no managed resource is left on it. Deleting it first leaves their cloud resources running, tracked by nothing: delete the XRs, wait until kubectl get managed -A is empty, and if that hangs, keep the control plane and report what remains (charter/targets.md, Teardown and leftovers).

Abort on a failed precondition; do not proceed and report the symptom. A check that comes back blocked — not merely failed — means the run you are about to start cannot produce a valid result. Starting it anyway spends real time on a failure whose cause you already knew.

Detail: charter/targets.md — read it before a run on a Space, or one without a target flag (a --local E2E run reads e2e-test-configuration's local.md instead) — has which control plane a run lands on, how to read up ctx, how the group defaults to default, why a bad --kubeconfig silently goes local, what --public does and does not change, diagnosing context deadline exceeded, and teardown.


10. Language dispatch

Detect the composition language from functions/ and the test language from tests/ — separate axes, which a project may mix — with the table in languages/README.md, then read the file it names. New tests use the language of the existing tests, else the composition language if up tests in it, else YAML; a program that emits no CompositionTest or E2ETest (a Go program printing items: []) is not a test and sets nothing (why, and the flags).

Detail: charter/generators.md has the --language slugs and what each generator emits: which up project init templates are v1 and which v2, and how to tell from the XRD, how up xrd generate drops every constraint, and the embedded functionRef name formula (and the unknown function it explains).