/** * The deep observation contract (#1014) — what a lexicon's * `observeResourcesDeep()` is allowed to mean, and how a live property tree is * normalized before anything diffs it. * * `describeResources()` (./observation.ts) answers *whether* a declared entity * exists and carries a handful of scrubbed outputs. That is thin by design, and * it is why `lifecycle diff --live` only fires on a changed status, a replaced * physical id, or a changed stack output. The drift people actually care about * — a hand-edited security-group rule, an inline policy added in the console, a * flipped bucket setting — lives one level down, in properties nobody was * reading. * * This module adds the second, optional read: a normalized live property tree * per declared entity. It composes with the tri-state contract rather than * replacing it — {@link DeepObservationResult} carries the same * {@link UnobservedEntity} map, so a deep read that fails for one entity is * NOT-OBSERVED with a reason. A thin deep result is never allowed to pass for a * clean one. * * ## The hard half is noise, not reading * * A raw live model is mostly fields nobody declared and nobody changed: arns, * timestamps, generation counters, status subtrees, values the API filled in, * fields a controller owns, and orderings that carry no meaning (tag order, * policy statement order). Diffing that raw is all noise. So the contract is * three parts, and a lexicon implements the last two: * * 1. **The normalization pass** (here, shared) — canonical key order, * hook-driven array order, secret masking, non-JSON values collapsed to * {@link UNRESOLVED}. * 2. **The pruning hook** ({@link DeepNormalizationHooks.prune}) — the * lexicon names its own read-only / server-populated / controller-managed / * provider-defaulted fields. * 3. **The ordering hook** ({@link DeepNormalizationHooks.orderKey}) — the * lexicon names which arrays are sets, and by what key they canonicalize. * * Both hooks are applied to the *declared* tree and the *live* tree with the * same rules, so the two sides are compared in the same shape. A hook sees * which side it is normalizing ({@link DeepNode.side}) and whether the same path * exists on the other side ({@link DeepNode.counterpart}) — that second flag is * what makes default subtraction expressible: a provider default is only noise * when nobody declared the property. */ import type { UnobservedEntity } from "./observation.js"; /** * A live property tree for one declared entity, already normalized by the * lexicon's hooks. */ export interface DeepResourceObservation { /** Entity type (e.g. `AWS::S3::Bucket`) — the same string `ResourceMetadata.type` carries. */ type: string; /** Provider-assigned physical id, when the reader knows it. Correlates to `ResourceMetadata.physicalId`. */ physicalId?: string; /** The normalized live property tree. JSON-safe. */ properties: Record; /** * Path → the field manager that owns it, where the substrate records one * (#1189). Additive to the v1 envelope: a lexicon with no per-field ownership * omits it, and every consumer behaves exactly as before. * * Only Kubernetes has this — the API server maintains `managedFields` * server-side. It is the difference between "this field is foreign" and * "`hpa-controller` owns `spec.replicas`", which are the same category and * very different answers to an operator. */ fieldOwners?: Record; } /** * The deep observation envelope. Explicitly versioned and discriminated by * `deepObservation: "v1"`, for the same reason {@link * import("./observation.js").ObservationResult} is: the shape is a wire format * consumers branch on. * * There is deliberately no bare-map alternative here. `describeResources()` * accepts one for backward compatibility with lexicons written before #1089; * `observeResourcesDeep()` is new, so every implementation states its holes * from day one. */ export interface DeepObservationResult { /** Discriminant + wire version. */ readonly deepObservation: "v1"; /** OBSERVED-PRESENT, keyed by chant entity name. */ resources: Record; /** * NOT-OBSERVED, keyed by chant entity name — the entities whose *properties* * could not be read, with a total reason. An entity here may well exist and * may well be reported present by the thin `describeResources()`: the two * reads have independent verdicts, and a deep hole is a hole in the property * surface, not a claim about existence. */ unobserved?: Record; } /** Normalized form every consumer works with. Both maps always present. */ export interface NormalizedDeepObservation { resources: Record; unobserved: Record; } /** True when `value` is the versioned {@link DeepObservationResult} envelope. */ export declare function isDeepObservationResult(value: unknown): value is DeepObservationResult; /** Build a {@link DeepObservationResult}. Lexicons use this rather than writing the discriminant by hand. */ export declare function deepObservation(resources: Record, unobserved?: Record): DeepObservationResult; /** * Normalize a deep result. `undefined` (a lexicon that returned nothing at all) * normalizes to two empty maps — which reads as "every declared entity's * properties were read and there were none", so a reader that means "I could * not look" must say so with `unobservedAll()` rather than returning nothing. */ export declare function normalizeDeepObservation(value: DeepObservationResult | undefined): NormalizedDeepObservation; /** * The placeholder a value takes when it is not JSON data — a class instance * (an unevaluated intrinsic like `Sub`/`Ref` in a declared tree), a function, a * symbol. The diff skips any path whose *declared* value is this: chant cannot * know what `Fn::Sub` resolves to without deploying, and guessing would report * every interpolated property as permanent drift. */ export declare const UNRESOLVED = ""; /** Value a masked (secret-bearing) property takes. Matches the thin path's convention. */ export declare const MASKED = "[REDACTED]"; /** * Property-name patterns that mark a value secret-bearing. Deliberately narrow * and key-name based: broadening this to e.g. `/key/i` would mask an AWS tag's * `Key` field and every `*KeyName` reference, which is how masking turns into * its own drift signal. * * Shared with the thin snapshot path (`./lifecycle/snapshot.ts`), which warns * on the same names — one list, so the two paths cannot disagree about what * counts as a secret. */ export declare const SENSITIVE_KEY_PATTERNS: readonly RegExp[]; /** True when a property name looks secret-bearing (see {@link SENSITIVE_KEY_PATTERNS}). */ export declare function isSensitiveKey(key: string): boolean; /** Which tree a hook is being invoked on. */ export type DeepSide = "declared" | "live"; /** One node the normalization pass offers to {@link DeepNormalizationHooks.prune}. */ export interface DeepNode { /** Entity type being normalized (e.g. `AWS::S3::Bucket`). */ entityType: string; /** Exact path from the property-tree root, array indices included: `Tags[0].Key`. */ path: string; /** * The same path with array indices erased: `Tags[].Key`. Hooks match on this * — an index is an artifact of the read, never part of the rule. */ pattern: string; /** Object key, or the array index as a string. */ key: string; /** The raw value at this node, before recursion. */ value: unknown; /** Which tree is being normalized. */ side: DeepSide; /** * Whether this path also exists on the other tree — i.e. a live property that * was declared, or a declared property that came back live. Matched on the * exact path or the index-erased pattern, so `Tags[0].Value` counts as * declared when source declares any `Tags[].Value` (array order is not * canonical until *after* normalization, so an exact-index match would be a * coin flip inside arrays). * * `"unknown"` when the pass is running one-sided — a reader normalizing its * own output before returning it has no declared tree to consult. It is a * third state on purpose: a hook that treats "I wasn't told" as "not * declared" would prune a *declared* property out of the live tree at read * time, and the drift on it could never be reported. * * This is what makes provider-default subtraction expressible without a third * hook: a defaulted value is noise only when nobody declared the property * (`side === "live" && counterpart === "absent"`). */ counterpart: "present" | "absent" | "unknown"; } /** One array element the normalization pass offers to {@link DeepNormalizationHooks.orderKey}. */ export interface DeepArrayElement { entityType: string; /** Index-erased path of the containing array: `Tags`, `Policy.Statement`. */ pattern: string; /** Exact path of the containing array. */ path: string; /** The element, already normalized (pruned, key-sorted, masked). */ element: unknown; index: number; side: DeepSide; } /** * The two hooks a lexicon supplies to opt into deep observation. Data on the * plugin, not methods on the read — the same rules must apply to the declared * tree, which no reader ever touches. */ export interface DeepNormalizationHooks { /** * Return true to drop this node (and everything under it) from the tree. * * This is where the lexicon names its noise classes: read-only and * server-populated fields (arns, timestamps, generation counters, status * subtrees), controller-managed fields, and provider defaults — the last of * those gated on `!node.counterpart` so a declared property is never pruned * out from under the diff. See {@link DeepNode.counterpart}. */ prune?(node: DeepNode): boolean; /** * Return a canonical sort key for one element of an array, or `undefined` to * leave that array's order alone. * * An array is reordered only when *every* element yields a key — a partial * answer is treated as "I don't know how to canonicalize this", which keeps * an order-significant list (a pipeline's stages, a CIDR precedence list) in * the order the provider returned it. */ orderKey?(element: DeepArrayElement): string | undefined; /** * Return true when this value cannot be known without deploying — it is * collapsed to {@link UNRESOLVED}, and the diff skips any path whose declared * value is UNRESOLVED, exactly as it already does for a non-JSON intrinsic. * * The pass collapses class-instance intrinsics (`Fn::Sub`, `Ref`) on its own; * this hook exists for the lexicons whose unevaluated references are plain * JSON data the pass cannot recognize — an ARM template's * `"[resourceId(...)]"` expression string is the motivating case (#1213). * Left alone, every such reference diffs against its evaluated live value and * reads as permanent drift. */ unresolved?(node: DeepNode): boolean; } /** Everything the pass needs besides the tree itself. */ export interface NormalizeDeepOptions { entityType: string; side: DeepSide; hooks?: DeepNormalizationHooks; /** * Paths present on the other tree, as produced by {@link deepPathSet}. Drives * {@link DeepNode.counterpart}. Omit and every node reports `"unknown"` — * the honest answer for a one-sided normalization. */ counterpartPaths?: ReadonlySet; } /** * Every path in a raw tree — both the exact form (`Tags[0].Value`) and the * index-erased pattern (`Tags[].Value`) — for {@link * NormalizeDeepOptions.counterpartPaths}. The two forms never collide (`[]` vs * `[0]`), so one set answers both questions. * * Computed on the *raw* tree, before pruning: whether a property was declared * cannot depend on whether the pruning rules kept it. */ export declare function deepPathSet(tree: Record): Set; export declare function normalizeDeepProperties(tree: Record, options: NormalizeDeepOptions): Record; /** Context {@link flattenDeepProperties} needs to address set-like arrays by key. */ export interface FlattenDeepOptions { entityType: string; side: DeepSide; hooks?: DeepNormalizationHooks; } /** * Flatten a normalized tree to `path → leaf value`. Leaves are JSON primitives, * {@link UNRESOLVED}, and empty containers (an empty object/array is itself a * value — `Tags: []` differs from no `Tags` at all). * * Arrays the ordering hook can key are addressed **by that key** * (`Tags[#env].Value`) rather than by position. Positional paths would make the * diff shift-sensitive: one tag added in the console renames every tag after * it, so a single new tag reports as a change to every other tag and a * baseline entry stops matching the moment the set changes. Keying holds a * property's identity still while the set around it moves. * * Positional paths remain for arrays the hook cannot key, arrays whose keys * collide, and keys too long to read. */ export declare function flattenDeepProperties(tree: Record, options?: FlattenDeepOptions): Map; /** Structural equality over JSON-shaped values, order-sensitive (post-canonicalization). */ export declare function deepValueEqual(a: unknown, b: unknown): boolean; //# sourceMappingURL=deep-observation.d.ts.map