Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion docs/src/content/docs/concepts/effect-receipts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,16 @@ export const dbMigration = EffectReceipt("dbMigration", {

Two flavors. **Existence** — the receipt's presence is the witness; "will be created" means "will fire." **Hash** — the witness is a digest of the inputs, so changed inputs re-propose the effect: bump `version` and the next plan shows the migration firing again.

The declaration is core, but the storage is per lexicon. A materialization row turns the declaration into a real resource in the estate — the aws row materializes an `AWS::SSM::Parameter`, plain `String`, at `/chant-receipts/<stack>/<env>/<effect>`. The path derives from the same ownership-block fields that stamp [ownership markers](/chant/configuration/config-file/#ownership), with the environment explicit, so receipt identity and resource identity come from one source. A lexicon that cannot observe its receipt type reports staleness as `unobserved`, loudly — never a wrong answer.
The declaration is core, but the storage is per lexicon. A materialization row turns the declaration into a real resource in the estate, and two of them exist today.

| Lexicon | Materialized as | Address | Where the value lives |
|---------|-----------------|---------|-----------------------|
| aws | `AWS::SSM::Parameter`, plain `String` | `/chant-receipts/<stack>/<env>/<effect>` | the parameter's `Value` |
| k8s | `K8s::Core::ConfigMap` | `chant-receipt.<stack>.<env>.<effect>`, in `k8s.receipts.namespace` (`default` when unset) | `data.expectation` |

Both addresses derive from the same ownership-block fields that stamp [ownership markers](/chant/configuration/config-file/#ownership), with the environment explicit, so receipt identity and resource identity come from one source. The separator differs only because the substrate's naming does: an SSM path is `/`-separated and a ConfigMap name is a DNS subdomain, and in each case the separator is a character no segment may contain, so two different identities can never render the same address. A lexicon that cannot observe its receipt type reports staleness as `unobserved`, loudly, and never as a wrong answer.

A receipt is never in any apply set, which is exactly the shape a prune deletes. The k8s row therefore carries a `chant.intentius.io/effect-receipt` label beside the ownership marker, and `delete: "owned-only"` reports the receipt `retained` rather than sweeping it, the same treatment a generated-once Secret gets.

Staleness is then an ordinary observation: the live value differs from the expected one, or the receipt is absent. [`lifecycle plan`](/chant/cli/lifecycle/#lifecycle-plan-env) renders that as an `effect` row — "effect will fire: db-migration" — the reviewable signal that an apply is about to run something, not just write something.

Expand Down Expand Up @@ -74,6 +83,12 @@ A receipt is a readable parameter in your own account, not an entry in a tool's
aws ssm get-parameter --name /chant-receipts/my-stack/prod/db-migration
```

Or with the k8s row:

```bash
kubectl get configmap chant-receipt.my-stack.prod.db-migration -o jsonpath='{.data.expectation}'
```

Read-only IAM, no chant binary, no export step. Delete chant tomorrow and every receipt is still there, still legible, still telling you which effects ran against which inputs. The same walk-away-zero property the rest of the lifecycle model holds.

## Boundaries, kept deliberately
Expand Down
7 changes: 7 additions & 0 deletions lexicons/k8s/docs/src/content/docs/serialization.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,13 @@ The generated file includes:
- `metadata.name` auto-generated from export names (camelCase → kebab-case)
- Default labels and annotations injected from `defaultLabels()`/`defaultAnnotations()`

The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as
ConfigMaps named `chant-receipt.<stack>.<env>.<effect>` in `k8s.receipts.namespace`
(`default` when unset), holding the expectation under `data.expectation`. A receipt is
never a document in the manifest stream: the rows ride a trailing
`# chant:effect-receipts` comment, which `kubectl apply` ignores, because the
`effect()` step is a receipt's sole writer.

## Key conversions

| Chant (TypeScript) | YAML output | Rule |
Expand Down
7 changes: 7 additions & 0 deletions lexicons/k8s/src/codegen/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,13 @@ The generated file includes:
- \`metadata.name\` auto-generated from export names (camelCase → kebab-case)
- Default labels and annotations injected from \`defaultLabels()\`/\`defaultAnnotations()\`

The lexicon also materializes [effect receipts](/chant/concepts/effect-receipts/) as
ConfigMaps named \`chant-receipt.<stack>.<env>.<effect>\` in \`k8s.receipts.namespace\`
(\`default\` when unset), holding the expectation under \`data.expectation\`. A receipt is
never a document in the manifest stream: the rows ride a trailing
\`# chant:effect-receipts\` comment, which \`kubectl apply\` ignores, because the
\`effect()\` step is a receipt's sole writer.

## Key conversions

| Chant (TypeScript) | YAML output | Rule |
Expand Down
5 changes: 5 additions & 0 deletions lexicons/k8s/src/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,11 @@ export const k8sConfigSchema = z.strictObject({
roots: z.array(z.string()).optional(),
})
.optional(),
receipts: z
.strictObject({
namespace: z.string().optional(),
})
.optional(),
});

declare module "@intentius/chant/config" {
Expand Down
23 changes: 23 additions & 0 deletions lexicons/k8s/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,29 @@ export interface K8sChantConfig {
/** Kustomization directories to render into the build. */
roots?: string[];
};

/**
* Effect receipt settings (#2074, epic #1703).
*
* `namespace` is where this project's receipt ConfigMaps live. The name is
* derived from the ownership fields (`chant-receipt.<stack>.<env>.<effect>`,
* see `./effect-receipt-row.ts`); the namespace is the one part of the
* address the ownership block cannot answer, so it is declared here. Unset,
* receipts land in `default`, the same namespace every other namespace-less
* k8s read and write in this lexicon falls through to. It is never derived
* from the stack or the environment: a guessed namespace is one chant would
* have to create, and the receipt row creates nothing but the receipt.
*
* ```ts
* k8s: {
* receipts: { namespace: "chant-system" },
* } satisfies K8sChantConfig
* ```
*/
receipts?: {
/** Namespace the receipt ConfigMaps live in. Defaults to `default`. */
namespace?: string;
};
}

declare module "@intentius/chant/config" {
Expand Down
33 changes: 26 additions & 7 deletions lexicons/k8s/src/deep-observe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ import {
} from "./api/classify";
import { operationFor } from "./api/operation-surface";
import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
import { observeReceiptRowsDeep, receiptRowsFor } from "./receipt-store";

// Re-exported so a dynamic importer of this module (plugin.ts's
// `observeResourcesDeep`, a test) can get the reader and its hooks from one
Expand Down Expand Up @@ -159,11 +160,23 @@ export async function observeResourcesDeepK8s(
): Promise<DeepObservationResult> {
const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client");

const declared = [...options.entities].map(([entityName, entity]) => ({
entityName,
entityType: entity.entityType,
props: entity.props,
}));
// Effect receipts (#2074) are read by their own leg at the end: they carry
// no declared props, so every live path would be an unclaimed field (#2160)
// and their staleness is an `effect` row from the plan (#1832), never
// property drift. Reading them here keeps the deep read from calling a
// declared entity a hole; contributing an empty tree keeps it from calling
// one drift.
const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);

const declared = [...options.entities]
.filter(([entityName]) => !receiptRows.has(entityName))
.map(([entityName, entity]) => ({
entityName,
entityType: entity.entityType,
props: entity.props,
}));

const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];

let client;
try {
Expand All @@ -173,7 +186,7 @@ export async function observeResourcesDeepK8s(
return deepObservation(
{},
unobservedAll(
declared.map((d) => d.entityName),
everyName,
"read-failed",
MISSING_CLIENT_DETAIL,
options.entities,
Expand All @@ -185,7 +198,7 @@ export async function observeResourcesDeepK8s(
return deepObservation(
{},
unobservedAll(
declared.map((d) => d.entityName),
everyName,
outcome.kind === "unobserved" ? outcome.reason : "read-failed",
outcome.kind === "unobserved" ? outcome.detail : undefined,
options.entities,
Expand Down Expand Up @@ -267,5 +280,11 @@ export async function observeResourcesDeepK8s(
}
});

if (receiptRows.size > 0) {
const receiptObs = await observeReceiptRowsDeep(client, receiptRows);
Object.assign(resources, receiptObs.resources);
Object.assign(unobserved, receiptObs.unobserved);
}

return deepObservation(resources, unobserved);
}
38 changes: 31 additions & 7 deletions lexicons/k8s/src/describe-resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
import { operationFor } from "./api/operation-surface";
import { resolveK8sOwnerChain } from "./api/owner-chain";
import { gvkToTypeName } from "./spec/parse";
import { observeReceiptRows, receiptRowsFor } from "./receipt-store";

function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
const out: Record<string, unknown> = {};
Expand Down Expand Up @@ -357,11 +358,26 @@ export async function describeResources(
// looked for where it lives".
const queried: Record<string, string> = {};

const declared: Declared[] = [...options.entities].map(([entityName, entity]) => ({
entityName,
entityType: entity.entityType,
props: entity.props,
}));
// Effect receipt rows (#2074) are read by their own leg below: the applier
// never wrote them (#1832), and their declaration carries no props, so the
// generic sweep has no `metadata.name` to query by and would report a hole
// where the receipt leg has a real answer. Their addresses come from the
// build output's receipt comment, which is the serializer's one rendering of
// the derivation (./effect-receipt-row.ts).
const receiptRows = receiptRowsFor(options.entityNames, options.buildOutput);

const declared: Declared[] = [...options.entities]
.filter(([entityName]) => !receiptRows.has(entityName))
.map(([entityName, entity]) => ({
entityName,
entityType: entity.entityType,
props: entity.props,
}));

// A whole-lexicon failure below is a hole for the receipts too: nobody
// looked at those either, and a connect that never happened proves nothing
// about a receipt's presence.
const everyName = [...declared.map((d) => d.entityName), ...receiptRows.keys()];

// Connect first. The binding check lives here, so a bound-but-mismatched
// context throws before any resource is read — core turns that into
Expand All @@ -374,7 +390,7 @@ export async function describeResources(
return observation(
{},
unobservedAll(
declared.map((d) => d.entityName),
everyName,
"read-failed",
MISSING_CLIENT_DETAIL,
options.entities,
Expand All @@ -386,7 +402,7 @@ export async function describeResources(
return observation(
{},
unobservedAll(
declared.map((d) => d.entityName),
everyName,
outcome.kind === "unobserved" ? outcome.reason : "read-failed",
outcome.kind === "unobserved" ? outcome.detail : undefined,
options.entities,
Expand Down Expand Up @@ -507,6 +523,14 @@ export async function describeResources(

await addRuntimeChildren(client, resources, unobserved, options.owned, declared);

// The receipt leg last, so its answers are the ones that stand for the
// receipt entities, because nothing above ever looked at one.
if (receiptRows.size > 0) {
const receiptObs = await observeReceiptRows(client, receiptRows);
Object.assign(resources, receiptObs.resources);
Object.assign(unobserved, receiptObs.unobserved);
}

return observation(resources, unobserved, queried);
}

Expand Down
Loading
Loading