diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c0bf577 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,21 @@ + + +## Before review + +- [ ] **If this revises a merged design proposal:** it adds a decision record under that proposal's `decisions/` directory, or says below why none is needed. + + + +## DCO + +- [ ] Commits are signed off (`git commit --signoff`). diff --git a/design-proposals/README.md b/design-proposals/README.md index 83fba99..55382bd 100644 --- a/design-proposals/README.md +++ b/design-proposals/README.md @@ -58,8 +58,111 @@ Proposals are reviewed in GitHub PRs. Once a proposal receives consensus from co We understand that real-world implementation may diverge from initial designs. This is normal and expected. -While it's helpful to update the proposal if the divergence is significant, the **codebase and user documentation are the final source of truth**. +Update the proposal when the divergence is significant. When the divergence came from a decision worth remembering — an approach that failed, a constraint you discovered, an alternative you picked instead — also write a [decision record](#decision-records) under that proposal and link it from the proposal's `Decisions` section. Editing the proposal alone loses the reasoning: the revised text reads as though it always said the current thing, and *why* the design changed course survives only in the pull-request diff. + +The **codebase and user documentation remain the final source of truth** for what the system does. A decision record is the source of truth for why it is that way. + +## Decision Records + +A design proposal is intent — what we think we should build, written before the work. A decision record is history — what we settled on, written once the question is closed. Both are useful; the second is the one we were missing. + +Records live **with the proposal they amend**, numbered per proposal: + +``` +./design-proposals//decisions/NNNN-short-slug.md +``` + +A decision is a decision *with respect to* something, and here that something is always a proposal. The [front-door table](../README.md) routes every other candidate elsewhere — a bug or scoped feature to [cozystack/cozystack](https://github.com/cozystack/cozystack/issues/new/choose), governance to an issue here — so a deliberation weighty enough to need a record, with no proposal to attach it to, has just demonstrated that it *is* a proposal. Write that instead. + +Placing records under their proposal means both link directions come for free, numbering collides only when two pull requests touch the same proposal, and a withdrawn proposal takes its decisions with it instead of orphaning entries in a global log. + +| | Design proposal | Decision record | +|---|---|---| +| Written | before the work, to get agreement | once the question is settled | +| Answers | "should we, and how might we?" | "what did we decide, and why not the alternatives?" | +| Edited after merge | yes, as the design evolves | never — superseded by a new record | +| Length | as long as it needs to be | one page | +| Review | consensus from maintainers | one maintainer checks it for accuracy | + +Both have an alternatives section, and they are not the same list. A proposal weighs the options we could imagine *before* building. A record names the option that lost *during* building — frequently the proposal's own original design. + +### When to write one + +Write a decision record when a future contributor would otherwise have to reconstruct the reasoning from a pull-request thread. In practice: + +- Implementation contradicted an accepted proposal and the design changed course. +- Two viable approaches existed and we picked one for reasons that are not visible in the resulting code. +- We hit a constraint that now shapes the design — an upstream limitation, a Kubernetes semantic, a vulnerability class. +- We deliberately decided *not* to do something, and the question keeps coming back. + +Do not write one for: + +- Routine code choices that the diff and the tests already explain. +- Operator-facing how-to — that is user documentation, and it belongs on [the website](https://cozystack.io/docs/). +- The mechanics of the product repository's own workflow (release process, changelog conventions) — those live next to the code in [cozystack/cozystack](https://github.com/cozystack/cozystack). +- A decision that has not been made yet. That is a design proposal, or an open question inside one. +- A contract that code can enforce. See [What belongs in code instead](#what-belongs-in-code-instead). + +### How to write one + +1. Copy [`decision-template.md`](./decision-template.md) to the proposal's `decisions/` directory, taking the next free number *for that proposal*: + + ``` + ./design-proposals/compute-plane/decisions/0002-short-slug.md + ``` + +1. **Title the decision, not the topic.** `storageClass is immutable after creation`, not `storageClass immutability`. Someone scanning the directory should learn what we decided from the filename alone. + +1. **Keep it to a page.** Link out to the proposal, the code and the pull requests for detail. A record that grows into a second design document will not get read. + +1. **Link the pull request where the decision was argued** in `Decided in:`, and **source every rejected alternative** to the comment or pull request it came from. A record is checkable for accuracy only if its claims are traceable; alternatives written from memory are where inaccuracy gets in. + +1. **Link it from the proposal's `Decisions` section**, newest first. + +1. As with all commits in CNCF projects, sign the commit for the DCO check: + + ```bash + git commit --signoff + ``` + +To see the records across all proposals, newest first: + +```bash +git log --diff-filter=A --date=short --format='%ad %an' --name-only \ + -- 'design-proposals/*/decisions/[0-9]*.md' +``` + +### What belongs in code instead + +Where a decision's content is a fact about a type, a field or an invariant that code must respect, its primary home is **a comment at that site plus a test that fails when the invariant is violated**. The record links to that site rather than restating it. + +Cozystack already does this well. The `computeplane` release-name invariant is enforced at `packages/extra/computeplane/templates/check-release-name.yaml` and pinned by `packages/extra/computeplane/tests/release_name_test.yaml`, whose suite comment carries the mechanism — the aggregated API rebuilds HelmRelease specs without `spec.releaseName`, so the release name is always the object name. That guard is a more reliable record than any prose, and it stays more precise, because a test fails when it goes stale and a paragraph does not. + +Without this rule the log fills with restatements of things the code already enforces. + +### Status and immutability + +| Status | Meaning | +|---|---| +| `Accepted` | Current. The decision stands. | +| `Superseded by NNNN` | A later record replaced it. The original text stays exactly as written. | +| `Reverted` | We undid it. Say what we do instead. | + +The header block and the body have different rules, and the distinction matters: + +- **The header block is maintained.** `Status`, `Superseded by` and `Implemented in` must track reality — the lifecycle depends on it, and a stale `Implemented in: not yet` makes the record actively misleading. +- **The prose below it is frozen.** Once merged, the body of an accepted record is not edited. Fix typos and broken links; change nothing else. If the decision changes, write a new record and mark the old one `Superseded by NNNN`. + +That second rule is what makes these records a history instead of a second set of documents to keep current. A record that gets quietly rewritten is worth no more than the proposal it was meant to supplement. + +### Review + +A decision record documents a decision that has already been made. Review therefore checks that the record is **accurate** — not whether the reviewer agrees with it. One maintainer's approval is enough, and it should be quick. + +This is a deliberately lighter bar than the consensus a design proposal needs, and it applies *only* to records. If review turns into re-litigating the decision, that is a signal the decision was not actually settled. Close the pull request and open a design proposal or an issue instead. ## Inspiration -This process is inspired by [KubeVirt's design proposals](https://github.com/kubevirt/community/tree/main/design-proposals) and [Kubernetes Enhancement Proposals](https://github.com/kubernetes/enhancements). \ No newline at end of file +This process is inspired by [KubeVirt's design proposals](https://github.com/kubevirt/community/tree/main/design-proposals) and [Kubernetes Enhancement Proposals](https://github.com/kubernetes/enhancements). + +The decision-record format follows [Michael Nygard's architecture decision records](https://cognitect.com/blog/2011/11/15/documenting-architecture-decisions) and [MADR](https://adr.github.io/madr/), trimmed to the sections we will actually fill in. diff --git a/design-proposals/compute-plane/README.md b/design-proposals/compute-plane/README.md index a64f978..73c9885 100644 --- a/design-proposals/compute-plane/README.md +++ b/design-proposals/compute-plane/README.md @@ -5,7 +5,7 @@ - **Author(s):** `@kvaps` - **Date:** `2026-06-23` - **Status:** Accepted -- **Revision (this PR):** Supersedes the preset-field revision (#27). ComputePlane is delivered as a Cozystack-owned **Tenant module** (`packages/extra/computeplane`) that, under the hood, deploys the ordinary `apps/kubernetes` chart with operator-fixed values, sourced through the existing PackageSource "source-only chart" mechanism (the same one NATS and SeaweedFS use — only the wrapped chart comes from `apps/` instead of `system/`). Like every Cozystack managed service, the module registers its **own `apps.cozystack.io` kind** (`ComputePlane`, via an `ApplicationDefinition` with `dashboard.module: true`) — its own API endpoint and input schema, served by `cozystack-api` and converted to a HelmRelease. So this is **not** the literal "no new kind" surface #27 aimed for; the honest positioning is: no new **CRD**, no new **controller / reconcile path**, and **no fields added to `kind: Kubernetes`** — the `ComputePlane` kind is a thin operator-owned wrapper over the unchanged `apps/kubernetes`. The tenant gets the *same* `kind: Kubernetes` cluster but owns none of its settings — only the knobs the operator exposes. The isolation mechanism (remote Flux apply onto Kamaji+KubeVirt, untrusted code behind a per-VM kernel boundary) is unchanged from the merged first revision; this revision is about the **delivery surface**. +- **Supersedes:** the preset-field revision (#27) — the delivery surface and why the preset fields were set aside are recorded in [decision 0001](./decisions/0001-computeplane-ships-as-an-operator-owned-module.md). ## Overview @@ -36,6 +36,10 @@ The capability is generic and intended to live in Cozystack core as a reusable p - **`design-proposals/kubernetes-nodes-split`** / **`kubernetes-nodes-hybrid-clusters`** (PR #8/#9): the substrate is the existing managed-`kubernetes` app (Kamaji + CAPI/KubeVirt); node-provisioning changes apply transparently. - **Deferred:** billing/metering of cluster resource and API consumption; secret delivery of managed-service connection strings into sandbox workloads; the per-instance/label granularity of the visibility control (Design §6). (Cross-tenant *sharing* of a cluster is **not** deferred — it is rejected by design; see Non-goals.) +## Decisions + +- [0001. ComputePlane ships as an operator-owned module, not as preset fields on `kind: Kubernetes`](./decisions/0001-computeplane-ships-as-an-operator-owned-module.md) — why the preset-field revision (#27) was set aside. + ## Context Today Cozystack already has every primitive needed *except* the glue that ties them into "deploy this catalog app onto a hardened, operator-controlled `kind: Kubernetes` the tenant does not administer": diff --git a/design-proposals/compute-plane/decisions/0001-computeplane-ships-as-an-operator-owned-module.md b/design-proposals/compute-plane/decisions/0001-computeplane-ships-as-an-operator-owned-module.md new file mode 100644 index 0000000..055f0c4 --- /dev/null +++ b/design-proposals/compute-plane/decisions/0001-computeplane-ships-as-an-operator-owned-module.md @@ -0,0 +1,47 @@ +# 0001. ComputePlane ships as an operator-owned module, not as preset fields on `kind: Kubernetes` + +- **Number:** `0001` +- **Date:** `2026-07-18` +- **Status:** Accepted +- **Deciders:** `@kvaps, @lllamnyp` +- **Proposal:** [`../README.md`](../README.md) +- **Decided in:** [`cozystack/community#33`](https://github.com/cozystack/community/pull/33) +- **Implemented in:** [`cozystack/cozystack#3280`](https://github.com/cozystack/cozystack/pull/3280) + +## Context + +The first ComputePlane revision ([#17](https://github.com/cozystack/community/pull/17), merged 2026-06-30) delivered a hardened, operator-controlled Kubernetes cluster for running code-executing catalog apps — notebooks, workflow code nodes, plugin systems — behind a per-VM kernel boundary instead of as shared-kernel pods on the management cluster. It was shaped as a tenant module selected by a single string (`computePlane: ""`), with the cluster's HelmRelease rendered inline in `apps/tenant/templates`. + +[#26](https://github.com/cozystack/community/issues/26), filed by @myasnikovdaniil, reopened the delivery question and forced the revision: the cluster a ComputePlane runs on *is* an ordinary managed `kind: Kubernetes` — Kamaji control plane, KubeVirt-VM workers, operator-held kubeconfig — so ComputePlane should not become a distinct kind with a parallel cluster implementation, reconcile loop and RBAC surface. + +@lllamnyp's [review on #17](https://github.com/cozystack/community/pull/17#pullrequestreview-4593662101) was an approval, and it argued something different that turned out to be load-bearing here: that "hidden from the tenant" had been conflated with the security boundary, and the real justification for withholding cluster access is **tamper-resistance** — withhold admin, not visibility. #26 quoted that approval in support of its own case, but the "not a distinct kind" argument is #26's alone. + +Revision [#27](https://github.com/cozystack/community/pull/27) answered #26 by putting the posture directly on the existing app as two orthogonal user-facing preset fields: `isolationProfile` (`standard` | `sandbox` | `cluster-meshed`) × `componentProfile` (`minimal` | `standard-addons` | `gpu`). A ComputePlane would then be nothing more than `kind: Kubernetes` with `isolationProfile: sandbox`. It was closed unmerged on 2026-07-13, superseded by [#33](https://github.com/cozystack/community/pull/33) the same day. + +The isolation mechanism itself was never in question at any point — one-way remote Flux apply via `HelmRelease.spec.kubeConfig.secretRef`, untrusted code behind a disposable guest kernel, single-tenant, scoped egress. Only the delivery surface was contested. + +## Decision + +ComputePlane ships as a Cozystack-owned **tenant module** (`packages/extra/computeplane`) that provisions a hardened `kind: Kubernetes` by wrapping the unchanged `apps/kubernetes` chart with operator-fixed values, sourced through the existing PackageSource source-only-chart mechanism that NATS and SeaweedFS already use. + +Because a tenant module is registered through an `ApplicationDefinition`, ComputePlane does get its own `apps.cozystack.io` kind, with its own API endpoint and input schema — the same CRD-free way every managed service is registered. So the accurate claim is not "no new kind": it is **no new CRD, no new controller or reconcile path, and no new fields on `kind: Kubernetes`**. Catalog apps route onto the cluster through a `placement: ManagementPlane | ComputePlane` field on `ApplicationDefinition`. + +## Why not the alternatives + +- **User-facing preset fields on `kind: Kubernetes`** ([#27](https://github.com/cozystack/community/pull/27), closed unmerged). Putting the posture on the app object turns an operator's choice into tenant-editable API surface, and the entire value of a sandbox is that the tenant *cannot* weaken it. The durable reason is structural, not a matter of how the chart is packaged today: capabilities and schemas are per-kind, so two postures over one chart need two `ApplicationDefinition`s; `ApplicationDefinitionSpec` ([`api/v1alpha1/applicationdefinitions_types.go`](https://github.com/cozystack/cozystack/blob/main/api/v1alpha1/applicationdefinitions_types.go)) has no fixed-values facility, and the conversion makes the tenant's spec *become* the Helm values (`Values: app.Spec`, [`pkg/registry/apps/application/rest.go`](https://github.com/cozystack/cozystack/blob/main/pkg/registry/apps/application/rest.go)), so operator-fixed values have nowhere to live but a chart. A narrow `openAPISchema` does not substitute: a field you can default is a field the tenant can override, and a field absent from the schema receives the chart's own unhardened default. "Hardened and unreachable" is not expressible in a structural schema. +- **A distinct, heavyweight `kind: ComputePlane` with its own cluster implementation** ([#17](https://github.com/cozystack/community/pull/17) as merged). Duplicates the cluster reconcile and RBAC path, which was the substance of [#26](https://github.com/cozystack/community/issues/26). Note this decision does still register a `ComputePlane` kind — it departs from #26's literal "no new kind at all" — but as a thin wrapper over the unchanged app, which is what the objection was actually about. +- **The single-string `computePlane:` module rendered inline in `apps/tenant/templates`** (the first revision's implementation). Right delivery shape, wrong packaging: the cluster HelmRelease was inlined into the tenant chart instead of shipping as a first-class chart with its own PackageSource, the way `extra/seaweedfs` does. +- **Hardening containers in the tenant namespace instead.** Rejected as the primary boundary: hardening does not make container isolation multi-tenant, and it breaks the apps in scope. +- **gVisor or a sandboxed runtime as the primary boundary.** Rejected for incomplete syscall coverage and no blast-radius containment on a kernel panic. Still valid as an *inner* layer for per-task sandboxes inside the cluster, and tracked as a future runtime option rather than dismissed. + +## Consequences + +- The hardening is tamper-proof by construction — the argument from the [#17 review](https://github.com/cozystack/community/pull/17#pullrequestreview-4593662101). The cluster's values live entirely in an operator-owned chart and the tenant holds no admin kubeconfig, so "withhold admin" and "own the configuration" become one fact rather than two features to keep in sync. +- `kind: Kubernetes` gains no fields and stays tenant-generic. Node-provisioning and addon changes to the app apply to ComputePlane transparently. +- The cost is composability: several differently-hardened postures per tenant now require separate module variants rather than a combination of two fields. The `placement: ` path that would give a tenant N sandbox clusters is deferred. +- The `computeplane` release name is load-bearing. It is enforced in code rather than restated here: [`templates/check-release-name.yaml`](https://github.com/cozystack/cozystack/blob/main/packages/extra/computeplane/templates/check-release-name.yaml) fails the render on a non-canonical name, and [`tests/release_name_test.yaml`](https://github.com/cozystack/cozystack/blob/main/packages/extra/computeplane/tests/release_name_test.yaml) pins it, with the mechanism in the suite comment. +- **The ordering constraint against [#39](https://github.com/cozystack/community/pull/39) is now live.** #33 was asked to co-land with #39 or land after it, because once tenants can set `computeplane` the release-name invariant stops being a markdown disagreement and becomes a migration of live Kamaji clusters holding tenant data. #3280 merged on 2026-07-29 and #39 is still open, so that migration is now a real cost carried by whoever lands #39, rather than a change that purely composes with this one. + +## Revisit if + +[#39](https://github.com/cozystack/community/pull/39) lands and changes how module kinds are registered, or a concrete need appears for several simultaneous, differently-hardened compute clusters per tenant — the case the single-module shape does not serve and the deferred `placement: ` path would. diff --git a/design-proposals/decision-records/README.md b/design-proposals/decision-records/README.md new file mode 100644 index 0000000..f3ba2ee --- /dev/null +++ b/design-proposals/decision-records/README.md @@ -0,0 +1,117 @@ +# Decision records + +- **Title:** `Record architectural decisions alongside the proposals they amend` +- **Author(s):** `@myasnikovdaniil` +- **Date:** `2026-08-20` +- **Status:** Review + +## Overview + +This proposal introduces **decision records** to this repository: short, dated notes on architectural decisions the project has already made, and why — stored under the design proposal each one amends. + +A design proposal is intent, written before the work. When implementation contradicts it we revise the proposal in place, so the proposal ends up reading as though it always said the current thing, and the reasoning that changed our minds survives only in a pull-request diff. That reasoning is the most expensive thing we produce and the easiest to lose. This proposal gives it a home. + +## Scope and related proposals + +The operative rules — when a record is required, the template, the numbering, the immutability rule, the review bar — live in [`design-proposals/README.md`](../README.md#decision-records) and [`design-proposals/decision-template.md`](../decision-template.md), not here. A merged proposal is "a reference, not a binding spec", so this document states the change and the rationale; the process text is the law. + +## Decisions + +- [0001. Decision records live with the proposal they amend](./decisions/0001-decision-records-live-with-their-proposals.md) — why records are per-proposal rather than a root-level log. + +## Context + +### The problem + +Proposal drift is not hypothetical here; it is the normal case, and it is already happening in a way that loses reasoning: + +- [#42](https://github.com/cozystack/community/pull/42) and [#44](https://github.com/cozystack/community/pull/44) rewrote the database-horizontal-autoscaling proposal after an implementation spike disproved its load-bearing premise — that the autoscaler could be the enforced single owner of the application's `replicas` value. +- [#53](https://github.com/cozystack/community/pull/53) revised that proposal again after a live PoC exposed a CloudNativePG version floor: CNPG 1.27's `Cluster` `/scale` subresource lacked `status.selector`, but [cloudnative-pg#8996](https://github.com/cloudnative-pg/cloudnative-pg/pull/8996) added it in CNPG 1.28.4, 1.29.2 and 1.30.0, so the resolution was a version bump rather than a design change. +- [#40](https://github.com/cozystack/community/pull/40), [#41](https://github.com/cozystack/community/pull/41) and [#36](https://github.com/cozystack/community/pull/36) are the same shape: an implementation finding rewriting an accepted proposal, with the *why* left in the pull-request body. +- The compute-plane proposal carried a `Revision (this PR):` paragraph in its metadata block because that rationale had nowhere else to live. "This PR" stops resolving the moment the next revision lands. + +Three improvised solutions to one problem already exist in this repository — that metadata paragraph, the database-autoscaler's `Why this changed` section, and its spike appendix. None of them is protected from the next in-place edit. + +### Prior art + +The projects with the largest proposal processes split two ways on this, and the split is instructive. + +| Project | Merged proposal | How superseded reasoning survives | +|---|---|---| +| [Rust RFCs](https://github.com/rust-lang/rfcs) | Frozen — "once accepted, RFCs should not be substantially changed… More substantial changes should be new RFCs, with a note added to the original RFC" | A new RFC, plus a note on the original | +| [Python PEPs](https://peps.python.org/pep-0001/) | Frozen — "PEPs are no longer substantially modified after they have reached the Accepted, Final, Rejected or Superseded state" | `Replaces` / `Superseded-By` headers, `Resolution` link | +| [Kubernetes KEPs](https://github.com/kubernetes/enhancements/blob/master/keps/sig-architecture/0000-kep-process/README.md) | Living, edited per release | `superseded-by` / `replaces` metadata and an `Implementation History` section — which records **milestones and dates, not reasoning** | + +Cozystack is in the Kubernetes camp by explicit choice: proposals here are a reference rather than a binding spec, and the codebase is the source of truth. That choice is right for this project, and it is also exactly why the gap exists — Rust and Python do not need decision records because freezing the proposal makes supersession the only way to change anything, so the history is automatic. + +Projects that keep living proposals and want the reasoning tend to add a second, immutable document class: [Backstage](https://backstage.io/docs/architecture-decisions/) keeps an ADR log in its docs tree, and the [GOV.UK Design System](https://github.com/alphagov/govuk-design-system-architecture/blob/main/proposals/001-use-rfcs-and-adrs-to-discuss-proposals-and-record-decisions.md) adopted RFCs and ADRs together, dividing them exactly this way — the RFC explores options, the ADR records what was decided. + +## Goals + +- The reasoning behind a design change is recoverable a year later without reading a pull-request thread. +- A record is cheap enough to write that it actually gets written: one page, one reviewer, no new front-door concept. +- The record cannot be quietly rewritten into agreement with the present. +- Where code can enforce a decision, the code stays the primary record and the note does not duplicate it. + +### Non-goals + +- Not a second design document. A record that grows into one has failed. +- Not a changelog, and not user documentation. +- Not a governance process for making decisions — only for recording ones already made. +- Not retroactive. Backfilling every past decision is not proposed; records start from the ones being made now, plus [0001](../compute-plane/decisions/0001-computeplane-ships-as-an-operator-owned-module.md) as a worked example. + +## Design + +A record is a one-page markdown file at `design-proposals//decisions/NNNN-slug.md`, numbered per proposal, with a maintained header block and frozen prose. It carries Context, Decision, Why not the alternatives, Consequences, and Revisit if. + +Two rules carry the design: + +1. **The prose is frozen after merge; the header is maintained.** `Status`, `Superseded by` and `Implemented in` must track reality or the lifecycle breaks; the body must not, or the record becomes just another document that agrees with the present. +2. **Where a decision is a fact about code, the code is the primary record.** A comment at the site plus a test that fails when the invariant is violated, and the record links to it. Cozystack already does this better than prose does — the `computeplane` release-name guard and its test state the mechanism more precisely than the first draft of record 0001 managed to. + +The full rules are in [`design-proposals/README.md`](../README.md#decision-records). + +## User-facing changes + +None for users of Cozystack. For contributors: one new optional document type, one new template, and a pull-request checkbox asking whether a proposal-revising change needs a record. + +## Upgrade and rollback compatibility + +Not applicable — no code, no API, no cluster state. Rolling this back means deleting the template and the process section; existing records stay readable as ordinary markdown. + +## Security + +No new trust boundary, tenant input, RBAC surface or secret. Records are public documents in a public repository and must not carry cluster-identifying or client-identifying detail. + +## Failure and edge cases + +- **A record is written for a decision that was not actually settled** → review turns into re-litigating it. That is the signal; close the pull request and open a proposal or an issue instead. +- **Two pull requests claim the same number under one proposal** → whoever merges second renumbers on rebase. Per-proposal numbering makes this rare rather than constant. +- **The decision spans several proposals** → file it under the one it changes most and link it from the others; if it belongs to no proposal, it is a proposal. +- **The decision is a standing constraint on the platform rather than on one proposal** → out of scope here. See [Open questions](#open-questions). +- **A proposal is withdrawn** → its records go with it, which is correct; nothing is orphaned. +- **Nobody writes records** → the failure mode Backstage names in its own ADR001, and the reason enforcement is a reviewer checkbox rather than a convention. + +## Testing + +There is no CI in this repository — `.github/` contains issue templates only — so there is nothing to assert mechanically. Validation is that the next proposal-revising pull request either adds a record or says why it does not, and that the reviewer checkbox is the place that gets noticed. + +## Rollout + +1. This proposal, the process text, and the template land together. +2. [0001 under compute-plane](../compute-plane/decisions/0001-computeplane-ships-as-an-operator-owned-module.md) ships as a backfilled worked example, and [0001 under this proposal](./decisions/0001-decision-records-live-with-their-proposals.md) as one written by its own author from an argument they were present for. +3. A second record from a different subsystem — the database-horizontal-autoscaling rev1 rejection — is requested on [#53](https://github.com/cozystack/community/pull/53) from the contributor who owns that argument, rather than backfilled here by someone who does not. +4. The improvised in-proposal narratives are extracted as their proposals are next revised. Compute-plane's `Revision (this PR):` field is retired here, since this change already touches that proposal; the database-autoscaler's `Why this changed` section and spike appendix go with the record requested on [#53](https://github.com/cozystack/community/pull/53). + +## Open questions + +- **Where does a design principle live?** Some constraints surfaced by a decision are not about that proposal at all — that `ApplicationDefinition` has no operator-fixed-values facility, and that settability and defaultability are the same property in a structural schema, bind every operator-owned module anyone builds next. A record filed under one proposal is the wrong home, and there is no principles document in `cozystack/cozystack` today. Tracked separately so this log does not absorb it by default. +- **Does the lighter review bar need explicit maintainer agreement?** One approval for a record, against consensus for a proposal, is a governance change and is called out as one rather than merged as documentation. + +## Alternatives considered + +- **A root-level `decisions/` log with a global sequence.** The original shape of this proposal. Rejected; the argument is recorded in [0001](./decisions/0001-decision-records-live-with-their-proposals.md). +- **Freeze merged proposals instead, Rust/PEP style.** Supersession then produces the history for free and no second document class is needed. Rejected as a heavier governance change that contradicts the existing "reference, not a binding spec" position and would make every implementation finding a new proposal — but it is the option the largest peers picked, and it remains the coherent alternative if records are not written in practice. +- **Leave the reasoning in pull-request bodies and rely on search.** This is the status quo, and the six pull requests in Context are what it produces: reasoning that is technically present and practically unfindable, attached to a diff rather than to the design. +- **Put the reasoning in the proposal itself, in a revision-history section.** Already tried three times here, in three different shapes, none protected from the next in-place edit. It also makes the proposal longer exactly where a reader wants it shorter. +- **Record decisions only in code comments and tests.** Correct for anything code can enforce, and now a rule rather than an alternative. It does not cover decisions about what *not* to build, or ones whose subject is a constraint rather than a line of code. diff --git a/design-proposals/decision-records/decisions/0001-decision-records-live-with-their-proposals.md b/design-proposals/decision-records/decisions/0001-decision-records-live-with-their-proposals.md new file mode 100644 index 0000000..72bebf5 --- /dev/null +++ b/design-proposals/decision-records/decisions/0001-decision-records-live-with-their-proposals.md @@ -0,0 +1,40 @@ +# 0001. Decision records live with the proposal they amend + +- **Number:** `0001` +- **Date:** `2026-08-20` +- **Status:** Accepted +- **Deciders:** `@myasnikovdaniil, @lllamnyp` +- **Proposal:** [`../README.md`](../README.md) +- **Decided in:** [`cozystack/community#56`](https://github.com/cozystack/community/pull/56) +- **Implemented in:** [`cozystack/community#56`](https://github.com/cozystack/community/pull/56) + +## Context + +Decision records were first proposed as a root-level `decisions/` directory with a single global sequence — `decisions/0001-…`, `decisions/0002-…` — following Michael Nygard's convention and the layout `adr-tools` produces. That shape was written, reviewed and complete before the placement question was raised in review of [#56](https://github.com/cozystack/community/pull/56). + +The objection that reopened it came from the proposal's own template. The `Proposal:` field read `design-proposals//README.md` — **or `none`**, which presupposes that a decision can exist in this repository without a proposal. The repository's front-door table routes every candidate somewhere: a cross-cutting architectural change to a design proposal here, a bug or scoped feature to `cozystack/cozystack`, governance to an issue here. There is no residue. A deliberation weighty enough to need a record, with no proposal to attach it to, has demonstrated that it is a proposal. + +At the time of the decision the repository held five design proposals and routinely had five or so pull requests open at once. + +## Decision + +Records live under the proposal they amend, at `design-proposals//decisions/NNNN-slug.md`, numbered per proposal. The process text lives in `design-proposals/README.md` and the template at `design-proposals/decision-template.md`. There is no root-level `decisions/` tree and no global sequence, and the root README continues to route two document classes rather than three. + +## Why not the alternatives + +- **A root-level `decisions/` log with a global monotonic sequence.** The case for it is real and giving it up costs something. (a) A monotonic id is short, citable and survives a directory rename — `ADR 0007` is a durable reference in a way `compute-plane ADR 0002` is not, and external citations are exactly what a global id serves. (b) Nygard's convention and its tooling assume one log; `adr-tools` defaults to a single `doc/adr`, and choosing otherwise forfeits it. (c) "What did we decide lately?" is a real question a single directory answers by listing itself. (d) Decisions that span several proposals have no single home under per-proposal placement, and this nearly carried the argument. What decided it against: (a) is answered by accepting a more verbose but more informative citation; (b) by the fact that nothing in this repository would consume that tooling — there is no CI here at all, and the template already departs from Nygard's four sections; (c) by `git log -- 'design-proposals/*/decisions/[0-9]*.md'`, which answers it in any layout and better than directory order does, since it carries dates and authors; and (d) by scoping cross-proposal *principles* out of this log entirely rather than letting the log absorb them — see the proposal's open question. Against all four stands the concrete cost of the global sequence: with five pull requests routinely open, a global number collides constantly, and "whoever merges second renumbers on rebase" invalidates any citation already written down. Per-proposal numbering collides only when two pull requests touch the same proposal. +- **Freezing merged proposals instead, and superseding them — the Rust RFC and Python PEP model.** [Rust](https://github.com/rust-lang/rfcs) holds that "once accepted, RFCs should not be substantially changed"; [PEP 1](https://peps.python.org/pep-0001/) that "PEPs are no longer substantially modified after they have reached the Accepted, Final, Rejected or Superseded state". Under that model supersession is the only way to change anything, so the history is automatic and no second document class is needed. Rejected here because it contradicts this repository's existing position that merged proposals are "a reference, not a binding spec" and that the codebase is the source of truth, and because it would turn every implementation finding into a new proposal — a much heavier governance change than the one under discussion. It is the coherent alternative rather than a weak one, and it is the model the largest peer processes actually chose; [Kubernetes](https://github.com/kubernetes/enhancements/blob/master/keps/sig-architecture/0000-kep-process/README.md), which keeps living proposals as this repository does, has the same gap and fills it only with milestone dates. +- **Records in the product repository, next to the code.** Rejected: the decisions in scope are decisions about proposals, and the proposals are here. A decision whose subject is a fact about code has its primary home in a comment and a test at that site, which is now a rule rather than an alternative. +- **A separate repository for decisions.** Rejected as a third place to look for the same argument, for a repository that currently holds five proposals. + +## Consequences + +- Both link directions come for free: a reader browsing a proposal's directory sees its decisions, and a record's parent directory is its proposal. Nothing to maintain, and hand-maintained backlinks are the first thing to rot. The `Decisions` section in the proposal template becomes a convenience rather than the only path between the two. +- Citations become longer and qualified — `compute-plane ADR 0002` rather than `ADR 0014`. External references to a record are correspondingly more fragile if a proposal directory is ever renamed. +- A withdrawn proposal takes its decisions with it instead of orphaning entries in a global log. +- Cross-proposal standing constraints are now explicitly homeless, which is the honest outcome: they were never served by either layout, and this decision refuses to pretend otherwise by filing them under an arbitrary proposal. Finding them a home is tracked as an open question against `cozystack/cozystack`. +- Restructuring cost was paid once, while the log had one entry. It would not have stayed cheap: the review's argument for deciding now rather than later was that the move stops being mechanical once citations point at the global ids from other repositories. + +## Revisit if + +Cross-proposal decisions become common enough that scoping them out stops being tenable, or a principles document lands in `cozystack/cozystack` and turns out to be the natural home for records that bind more than one proposal. Also revisit if records are not being written in practice — that would make the frozen-proposal alternative the live option rather than this one. diff --git a/design-proposals/decision-template.md b/design-proposals/decision-template.md new file mode 100644 index 0000000..9b939d8 --- /dev/null +++ b/design-proposals/decision-template.md @@ -0,0 +1,84 @@ + +# NNNN. State the decision here, as a fact + +- **Number:** `NNNN` +- **Date:** `YYYY-MM-DD` +- **Status:** Accepted | Superseded by `NNNN` | Reverted +- **Deciders:** `@your-github-handle, @codecider` +- **Proposal:** [`../README.md`](../README.md) +- **Decided in:** `cozystack/community#NNNN` — the pull request or issue where it was argued +- **Implemented in:** `cozystack/cozystack#NNNN` — or `not yet` + + + +## Context + + + +## Decision + + + +## Why not the alternatives + + + +## Consequences + + + +## Revisit if + + + +--- + + diff --git a/design-proposals/template.md b/design-proposals/template.md index 176743a..cfb8afc 100644 --- a/design-proposals/template.md +++ b/design-proposals/template.md @@ -20,6 +20,18 @@ A reader who stops here should know what you're asking for and roughly why. --> Link them by repo path or URL. If this proposal must land before or after another, say so. Omit the section if there are no related proposals. --> +## Decisions + + + ## Context