/** * Status surface — reconciles the release ledger against live truth (#568, * epic #551 "Build & deploy observability"). * * Two ledgers, joined by one key. What's been built lives in the * content-addressed `BuildArchive` (../components/verbs/build-archive.ts, * ./build-ledger.ts). What we *recorded* deploying lives in the release * ledger (./release-ledger.ts). What's *actually running* is live truth: * ownership markers (../ownership.ts) plus `lifecycle diff --live` * (./live-diff.ts). This module is the join: for each component/env, line up * the latest recorded release against whatever live evidence is available, * and flag the four things that matter — * * - **unrecorded** — something is live/owned but no release record exists * for it. Someone (or some pipeline) deployed outside the recorded path. * - **stale** — a release record exists but nothing live corresponds to it * anymore (the component disappeared from observation). * - **drifted** — the environment's live/owned resources don't confirm the * recorded digest one way or the other from this join alone (recorded, * but live identity can't be read back to a digest) — surfaced as a * lower-confidence signal rather than silently treated as "reconciled". * - **reconciled** — a release record exists and live evidence (via * ownership) confirms the component is present and owned by chant. * - **unknown** — live evidence was requested and could not be read (#1089), * or was not requested at all. A component chant could not observe is never * reported `stale`: "the read failed" and "it is gone" are different facts. * * This is deliberately a light-touch reconciliation: chant's lexicons report * resource-level status (`ResourceMetadata`), not "the digest currently * running" (most lexicons have no notion of image digest at all — a * CloudFormation stack doesn't expose "the ECS task's image digest" through * `describeResources`). So the live axis here is presence/ownership, not a * digest re-derivation; digest-level truth is what the *release ledger* * itself provides, and cross-environment digest comparison * (`buildIsInBothEnvs`) is the query the epic actually asks for ("which * build is in prod, and is it the one tested in staging"). */ import type { ChangeSet, ChangeAction } from "./change-set.js"; import { type ReleaseRecord } from "./release-ledger.js"; import type { BuildLedgerEntry, ComponentBomSummary } from "./build-ledger.js"; import { type UnobservedReason } from "../observation.js"; /** One row of `chant components status [env]` — the per-component join of recorded vs live. */ export interface ComponentStatusRow { component: string; env: string; /** The latest recorded release for this component/env, if any. */ recorded?: ReleaseRecord; /** Build-ledger detail for the recorded digest, when discoverable (referrers, manifest digest, this artifact's own reproducibility — #614). */ build?: BuildLedgerEntry; /** * Component-level BOM aggregation summary (#614, * ../components/verbs/component-bom.ts), when the recorded digest's build * archive manifest is available (`opts.componentBomByDigest`). Reports * every leaf BOM (software SBOM + IaC config-BOM) the component's archive * carries and whether they compose 1:1 or as a real multi-artifact * assembly — independent of `build`/`buildsByDigest` (which stays scoped * to image entries), since a config-only/infra component with no image * still has a component BOM to report. */ componentBom?: ComponentBomSummary; /** Live reconciliation verdict for this row. */ reconciliation: "reconciled" | "unrecorded" | "stale" | "drifted" | "unknown"; /** Human-readable detail backing the verdict. */ detail: string; /** * Machine-readable "observed live", when live evidence was gathered (`--live`). * A consumer joining this row onto a graph node should read this rather than * string-matching `detail`. Absent when `--live` was not requested — and, since * #1089, also absent when live state could not be read at all: `false` means * "looked, not there", never "did not look". Read {@link unobserved} for that * case; a consumer that treats absent as "unknown" already handles it. */ live?: boolean; /** * Set when `--live` was requested and the observation could not read this * component (#1089). `live` is absent alongside it and `reconciliation` is * `unknown` — the row reports a hole rather than a verdict. */ unobserved?: { reason: UnobservedReason; detail?: string; }; /** * The owning deploy unit's raw status, when a lexicon reported it (AWS: the * component's own CFN stack via `describeStackStatus`). Lets a renderer paint a * richer palette than the reconciliation verdict — `healthy` green, * present-but-not-healthy amber (mid-deploy) / red (rollback/failed). */ stack?: LiveStackInfo; /** * How this component's own resources answered (behold#98). Present whenever * `--live` gathered evidence across a live-name mapping. * * `stack` above only exists where the substrate has a deploy object to read, * which is AWS and nowhere else — floci-az and floci-gcp have none, so a * consumer painting component status off `stack` has nothing to paint from * there. These counts are the substrate-neutral source for the same job: * they aggregate observations, which every lexicon produces, rather than a * provider-specific grouping object. `stack` stays as the richer enrichment * where it exists. */ resources?: ComponentResourceRollup; /** * Set when some but not all of this component's deploy units were observed * present (#1528). `live` is `false` — deployed means all of it — but a * consumer painting the row can distinguish "half up" from "gone", and the * missing unit names are the actionable part. */ partial?: { present: number; total: number; missing: string[]; }; } /** A component's owning deploy unit and its provider-native status. */ export interface LiveStackInfo { /** The deploy-unit name (e.g. the CloudFormation stack name). */ name: string; /** Provider-native status string, e.g. "CREATE_COMPLETE". */ status?: string; /** True when `status` is a terminal success state. */ healthy?: boolean; } export interface ComponentStatusResult { env: string; rows: ComponentStatusRow[]; /** Count of malformed ledger lines skipped (surfaced so a corrupted ledger is never silently invisible). */ malformedLedgerLines: number; } /** * Live evidence for one component, distilled from a `ChangeSet` entry * (./change-set.ts) — the same read-only classification `lifecycle plan` * already computes from ownership + `diffLive`. `chant components status` * doesn't recompute live/ownership logic; it reuses `buildChangeSet`'s * output, so "unrecorded deploy" and "drift" both key off the one * ownership-aware classification chant already trusts. */ export interface LiveComponentEvidence { /** True if this component name was observed live at all (declared+live, or orphan+live). */ live: boolean; /** * Set when the observation could not read this component (#1089). `live` is * `false` alongside it, but only because the boolean has nowhere else to go — * every consumer must branch on this field before believing `live: false`. */ unobserved?: { reason: UnobservedReason; detail?: string; }; /** The `ChangeSet` action chant's existing plan logic assigned, when the component maps to a tracked entity/resource name. */ action?: ChangeAction; /** Ownership verdict, when known. */ ownership?: "owned" | "foreign" | "unknown"; /** The owning deploy unit's raw status, when observed (AWS: the component's own * CFN stack). Surfaced onto `ComponentStatusRow.stack` for a richer palette. */ stack?: LiveStackInfo; /** * Set when some but not all of the component's deploy units were observed * present (#1528). `live` is `false` — a component is deployed when all of * it is — but "nothing observed live" would be a lie, and it was one: a * multi-unit component with a single absent unit reported exactly that * while its Helm releases sat deployed and healthy in the same row's * `stack` field. Consumers get the split and the names of what is missing. */ partial?: { present: number; total: number; missing: string[]; }; /** * How the component's own resources answered, before any merge collapsed * them (behold#98). Always present when evidence exists — a component that * maps to a single entity by identity gets a rollup of one, so a consumer * never has to branch on whether a live-name mapping happened to be * configured. */ rollup?: ComponentResourceRollup; } /** * Overlay per-component stack-presence evidence onto change-set evidence. * * The change-set axis (`liveEvidenceFromChangeSet`) is entity-keyed and, for * AWS, single-stack-per-env — it can't see a multi-stack component project where * each component owns its own stack (#57). `supplement` carries the direct * per-component stack observation (from a lexicon's `describeStackStatus`), which * is authoritative for **presence** (`live`) and **ownership**; the change-set's * `action` is kept, since drift is still assessed from the diff. A component in * only one map passes through unchanged. * * The change-set's `rollup` is kept too (behold#100). This merge rebuilds the * evidence object field by field, so anything not named here is dropped — and * `describeStackStatus` reports a stack, never per-resource counts, so the * supplement has no rollup to contribute. Before this, every component on a * lexicon that implements `describeStackStatus` lost the counts #1300 had just * computed. That is AWS and only AWS, which made the rollup absent on exactly * the substrate it was meant to be verified against: behold#98 shipped its * consumer against floci-az/floci-gcp rows, where no stack observer runs and * the field survived. */ export declare function mergeLiveEvidence(base: Map | undefined, supplement: Map): Map; /** * Component -> live entity/resource name(s) it owns (#598). Mirrors * `Component.liveNames` (../components/component.ts) without importing it — * this module stays decoupled from the typed authoring form, since a * hand-written JSON component or a future non-chant frontend can supply the * same mapping without going through `Component` at all. A component absent * from this map, or mapped to `undefined`/an empty array, falls back to its * own name as the sole live name — the original name == entity join. */ export type LiveNameMapping = Map; /** Resolve the live entity/resource name(s) a component owns: its explicit mapping, or `[component]` when none is given. */ export declare function resolveLiveNames(component: string, mapping?: LiveNameMapping): string[]; /** * How a component's own resources answered, one count per tri-state verdict. * * The merged verdict above is deliberately lossy — it answers "is this * component deployed" and nothing else. A consumer painting component status * without a deploy object to read (behold#98: floci-az and floci-gcp have no * CloudFormation stack, so `stack` below is absent and there is nothing to * colour from) needs the shape underneath: how many of the component's * resources were seen, how many were confirmed gone, how many nobody could * look at. Substrate-neutral by construction — it counts observations, not * provider objects. */ export interface ComponentResourceRollup { /** Resources this component owns, per the live-name mapping. */ total: number; /** Observed present. */ present: number; /** Looked for, reported missing. Never includes a resource nobody could read. */ absent: number; /** NOT-OBSERVED (#1089) — a hole, never counted as absence. */ unobserved: number; } /** * Build a `component -> live evidence` map from a `ChangeSet`. Components and * chant entities are different namespaces (a component's `name` need not * equal any single lexicon entity name), so callers that want live * reconciliation for a component whose live name(s) differ from its own name * pass `nameMapping` (typically projected from `Component.liveNames`, see * ../components/component.ts) — e.g. `new Map([["search-svc", * ["search-service-v2"]]])`. Without a mapping (or for a component with no * entry in it), the component's own name is used as its sole live name — the * identity-keyed join that was this function's only behavior before #598, * preserved here as the fallback so existing callers see no change. */ export declare function liveEvidenceFromChangeSet(cs: ChangeSet, nameMapping?: LiveNameMapping): Map; /** * Reconcile the release ledger against live evidence for one environment. * Pure — no I/O. Callers assemble `records` (./release-ledger.ts's * `readReleaseLedger`), `liveEvidence` (from a `ChangeSet` via * `liveEvidenceFromChangeSet`, or `undefined` when `--live` wasn't * requested), `builds` (a `component -> BuildLedgerEntry` lookup keyed by * digest, from ./build-ledger.ts), and `componentBomByDigest` (#614, a * `digest -> ComponentBomSummary` lookup, also from ./build-ledger.ts) ahead * of time. */ export declare function reconcileStatus(env: string, records: ReleaseRecord[], opts?: { liveEvidence?: Map; /** Build-ledger detail keyed by digest, when available. */ buildsByDigest?: Map; /** Component-level BOM aggregation summary keyed by digest, when available (#614). Independent of `buildsByDigest` — a config-only/infra component with no image entry still has a component BOM to report. */ componentBomByDigest?: Map; /** Component names known to exist (from `discoverComponents`) but with no release record at all — still reported as `unrecorded` when live evidence says they're running. */ allComponents?: string[]; }): ComponentStatusRow[]; /** * Answer "which build is in `envA`, and is it the one tested in `envB`" — * the single-query comparison the epic names explicitly — for one component * across two environments' release ledgers. */ export interface CrossEnvComparison { component: string; envA: string; envB: string; digestA?: string; digestB?: string; /** True only when both envs have a recorded digest and they match. */ same: boolean; } /** Compare the latest recorded digest for `component` between two environments' release records. */ export declare function compareAcrossEnvironments(component: string, envA: { name: string; records: ReleaseRecord[]; }, envB: { name: string; records: ReleaseRecord[]; }): CrossEnvComparison; //# sourceMappingURL=status.d.ts.map