@intentius/chant-lexicon-fountain 0.52.2 → 0.53.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,78 @@
1
+ /**
2
+ * fountain deep-observation noise rules (#1217).
3
+ *
4
+ * Plain data, imported statically by `plugin.ts`, because core applies the
5
+ * identical rules to the *declared* property tree — which no reader ever
6
+ * touches — before it diffs. Burying them inside the read would normalize the
7
+ * two sides differently and report everything as drift.
8
+ *
9
+ * ## Why a static table and not an ownership walk
10
+ *
11
+ * Kubernetes records `managedFields`, so the k8s row can subtract whatever a
12
+ * controller owns. A fountain REST payload never says who wrote a field, so
13
+ * this row follows the GCP precedent (`lexicons/gcp/src/deep-observe-hooks.ts`)
14
+ * — a hand-maintained table naming what the server populates. The cost is
15
+ * explicit: a server-set field nobody has listed reads as drift until it is
16
+ * listed. That is visible and fixable; an over-broad rule silently hides real
17
+ * drift, which is the worse of the two failures.
18
+ *
19
+ * Every value below is read off fountain's own Ecto schemas (`Environment`,
20
+ * `Vault`, `Agent`) and the JSON views that render them, not guessed from a
21
+ * sample payload.
22
+ *
23
+ * ## Secrets
24
+ *
25
+ * chant authors an environment's or vault's `secrets` as an ordered
26
+ * `{key, value}[]`, so the declared node holds real secret material. Core's
27
+ * key-name mask (`SENSITIVE_KEY_PATTERNS` matches `secrets`) collapses the
28
+ * whole node to `[REDACTED]` on both trees before any diff sees it, which is
29
+ * the correct outcome and also the limit of what this row can report: presence
30
+ * — an environment that declares no secrets and has some, or the reverse —
31
+ * never the key set, and never a value. Making the key set diffable needs
32
+ * fountain#148's reference model, where secrets stop being inline values.
33
+ *
34
+ * {@link fountainDeepNormalizationHooks.mask} adds one narrow rule on top:
35
+ * a string matching a known credential shape is collapsed wherever it appears,
36
+ * whatever the key is called. FTN001/FTN012 already refuse those at lint time;
37
+ * this is the backstop for a value that reached the live instance some other
38
+ * way, so a drift row can never print one.
39
+ */
40
+ import type { DeepNormalizationHooks } from "@intentius/chant/deep-observation";
41
+ export declare const ENVIRONMENT_TYPE = "Fountain::V1::Environment";
42
+ export declare const VAULT_TYPE = "Fountain::V1::Vault";
43
+ export declare const AGENT_TYPE = "Fountain::V1::Agent";
44
+ /**
45
+ * Top-level payload fields fountain writes and a caller cannot: the primary
46
+ * key, the `timestamps()` pair, the owning user, the virtual `*_count` rollups
47
+ * the `*_with_counts` reads attach, the avatar's derived media type, and `acp`
48
+ * (computed per request from the runtime, never stored).
49
+ *
50
+ * Pruned on BOTH sides and regardless of what source declared, since a user who
51
+ * writes one is writing something the API overwrites anyway.
52
+ *
53
+ * Matched on the whole pattern rather than its last segment: `name` and `id`
54
+ * also occur *inside* `skills[]` and `repositories[]`, where they are authored
55
+ * configuration. `SERVER_FIELDS` is shared with the import/export path so the
56
+ * two cannot disagree about what a caller may author.
57
+ */
58
+ export declare const FOUNTAIN_SERVER_FIELDS: ReadonlySet<string>;
59
+ /**
60
+ * Per-kind values fountain fills in when the request omits them, keyed by the
61
+ * index-erased pattern from the tree root.
62
+ *
63
+ * Noise only where **source never declared the property** — the
64
+ * `counterpart === "absent"` gate below. A default somebody wrote out
65
+ * explicitly is a fact worth diffing, and a later change away from it has to
66
+ * stay reportable.
67
+ *
68
+ * Straight off the Ecto `schema` blocks. `networking_type: "unrestricted"` is
69
+ * the one worth arguing about: subtracting it means an environment that never
70
+ * states its networking posture does not report one. That case is FTN010's, at
71
+ * lint time, where it is a build finding rather than drift; and the case this
72
+ * row exists for — a reviewed `limited` environment flipped to `unrestricted`
73
+ * in the UI — has a declared counterpart, so the gate keeps it and it reports
74
+ * as `changed`.
75
+ */
76
+ export declare const FOUNTAIN_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>>;
77
+ export declare const fountainDeepNormalizationHooks: DeepNormalizationHooks;
78
+ //# sourceMappingURL=deep-observe-hooks.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe-hooks.d.ts","sourceRoot":"","sources":["../src/deep-observe-hooks.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAsCG;AAEH,OAAO,KAAK,EAGV,sBAAsB,EACvB,MAAM,mCAAmC,CAAC;AAG3C,eAAO,MAAM,gBAAgB,8BAA8B,CAAC;AAC5D,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAChD,eAAO,MAAM,UAAU,wBAAwB,CAAC;AAEhD;;;;;;;;;;;;;GAaG;AACH,eAAO,MAAM,sBAAsB,EAAE,WAAW,CAAC,MAAM,CAKrD,CAAC;AAEH;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,iBAAiB,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAiBzF,CAAC;AAoDF,eAAO,MAAM,8BAA8B,EAAE,sBA0E5C,CAAC"}
@@ -0,0 +1,84 @@
1
+ /**
2
+ * fountain deep observation (#1217) — the fountain row of the deep-observe
3
+ * contract (#1014).
4
+ *
5
+ * `describeResources()` (./describe-resources.ts) answers whether a declared
6
+ * Environment/Vault/Agent exists and hands back its id and timestamps. That
7
+ * misses the drift the design was written for: an environment hand-edited in
8
+ * the fountain UI from `networking_type: limited` to `unrestricted`, an
9
+ * `allowed_vault_ids` allowlist widened, a skill repointed at an unpinned
10
+ * branch, a secret added to a reviewed sandbox. All of it lives one level
11
+ * down, in properties nobody was reading.
12
+ *
13
+ * ## The read is the thin path's read
14
+ *
15
+ * Transport, endpoint and auth are the applier's, unchanged
16
+ * (`FOUNTAIN_ENDPOINT` / `FOUNTAIN_TOKEN`), so plan reads the instance
17
+ * `fountainApply` writes. And the depth is free: fountain's list endpoints
18
+ * render the full record — `GET /api/environments` returns every configuration
19
+ * field the request schema accepts, not a summary — so there is no per-resource
20
+ * follow-up GET the way the AWS row needs Cloud Control on top of
21
+ * `describe-stack-resources`. One list per declared kind, cached, exactly as
22
+ * the thin path does it.
23
+ *
24
+ * ## The payload passes through
25
+ *
26
+ * fountain's JSON views name their fields the same way the request schema does
27
+ * (`networking_type`, `env_vars`, `skills`), so the live tree and the declared
28
+ * tree already speak one vocabulary — the AWS situation, not temporal's. The
29
+ * payload is therefore forwarded as-is and the noise rules
30
+ * (./deep-observe-hooks.ts) do the rest. A field fountain adds in a later
31
+ * release surfaces as `undeclared` until the table names it, which is the
32
+ * deliberate trade: visible and fixable beats silently dropped.
33
+ *
34
+ * One exception, and it is the reference edge. chant declares an agent's
35
+ * environment as a typed reference (`environment`), fountain stores the id it
36
+ * resolved to (`environment_id`). Passing the id through would report
37
+ * `<undeclared> -> <uuid>` on every clean read, so where source did not author
38
+ * `environment_id` itself the id is resolved back to the environment's name and
39
+ * emitted as `environment` — the same translation `exportResources()` does for
40
+ * the import path.
41
+ *
42
+ * ## Secrets: presence, never keys, never values
43
+ *
44
+ * Values are write-only upstream and are never read here at all. The secrets
45
+ * sub-resource is listed (keys and timestamps only) so that an environment or
46
+ * vault which declares no secrets and has some — somebody adding one to a
47
+ * locked-down sandbox — reports as drift. Core's key-name mask collapses the
48
+ * whole `secrets` node on both trees, so what a diff row can say is that
49
+ * secrets exist, not which. See the hooks module for why the key set itself is
50
+ * not expressible until fountain#148 lands.
51
+ *
52
+ * That listing is one extra request per observed Environment and Vault. A
53
+ * fountain tenant holds a handful of each, and the alternative — inferring
54
+ * presence from the newer payload's `secret_count` — would silently report
55
+ * "no secrets" against any instance predating that field.
56
+ */
57
+ import type { DeepObservationResult } from "@intentius/chant/lexicon";
58
+ import { type FountainHttp } from "./op/activities/fountain-apply.js";
59
+ import { fountainDeepNormalizationHooks } from "./deep-observe-hooks.js";
60
+ export { fountainDeepNormalizationHooks };
61
+ export interface FountainDeepObserveOptions {
62
+ environment: string;
63
+ buildOutput?: string;
64
+ entityNames: string[];
65
+ entities: Map<string, {
66
+ entityType: string;
67
+ props: Record<string, unknown>;
68
+ }>;
69
+ stack?: string;
70
+ /** Restrict to resources carrying the `managed-by: chant` marker. */
71
+ owned?: boolean;
72
+ /** Endpoint override (tests). Defaults to resolveEndpoint(). */
73
+ endpoint?: string;
74
+ }
75
+ /**
76
+ * Read the live property tree for each declared fountain entity.
77
+ *
78
+ * `http` is injectable for tests; the default reuses the applier's fetch client
79
+ * (bearer token from FOUNTAIN_TOKEN). A missing token is the whole-lexicon
80
+ * failure the thin path already names — every declared entity NOT-OBSERVED with
81
+ * `no-credentials`, never an empty tree, which would read as "nothing drifted".
82
+ */
83
+ export declare function observeResourcesDeepFountain(options: FountainDeepObserveOptions, injected?: FountainHttp): Promise<DeepObservationResult>;
84
+ //# sourceMappingURL=deep-observe.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deep-observe.d.ts","sourceRoot":"","sources":["../src/deep-observe.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAuDG;AAEH,OAAO,KAAK,EACV,qBAAqB,EAGtB,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EAML,KAAK,YAAY,EAClB,MAAM,gCAAgC,CAAC;AACxC,OAAO,EACL,8BAA8B,EAI/B,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAAE,8BAA8B,EAAE,CAAC;AAW1C,MAAM,WAAW,0BAA0B;IACzC,WAAW,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,QAAQ,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,CAAC,CAAC;IAC9E,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,gEAAgE;IAChE,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AA+ED;;;;;;;GAOG;AACH,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,0BAA0B,EACnC,QAAQ,CAAC,EAAE,YAAY,GACtB,OAAO,CAAC,qBAAqB,CAAC,CAkGhC"}
package/dist/index.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  export { fountainPlugin } from "./plugin.js";
2
2
  export { fountainSerializer } from "./serializer.js";
3
+ export { observeResourcesDeepFountain } from "./deep-observe.js";
4
+ export type { FountainDeepObserveOptions } from "./deep-observe.js";
5
+ export { fountainDeepNormalizationHooks, FOUNTAIN_SERVER_FIELDS, FOUNTAIN_DEFAULTS, } from "./deep-observe-hooks.js";
3
6
  export * from "./generated/index.js";
4
7
  export { fountainApply, fountainRun, DEFAULT_FOUNTAIN_BASE_URL } from "./op/activities/index.js";
5
8
  export type { FountainApplyArgs, FountainApplySummary, FountainRunArgs, FountainRunResult } from "./op/activities/index.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAGlD,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGnH,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AAG1C,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAC;AAIlD,OAAO,EAAE,4BAA4B,EAAE,MAAM,gBAAgB,CAAC;AAC9D,YAAY,EAAE,0BAA0B,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EACL,8BAA8B,EAC9B,sBAAsB,EACtB,iBAAiB,GAClB,MAAM,sBAAsB,CAAC;AAG9B,cAAc,mBAAmB,CAAC;AAIlC,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,yBAAyB,EAAE,MAAM,iBAAiB,CAAC;AACxF,YAAY,EAAE,iBAAiB,EAAE,oBAAoB,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAGnH,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAC9D,YAAY,EAAE,kBAAkB,EAAE,uBAAuB,EAAE,MAAM,8BAA8B,CAAC"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "bace62b6de51e843f06dbc2de56ec34020ca13898a12102e2629971b91c599a8",
4
+ "manifest.json": "cf9b6d5f839232eab09f425333d9c625e1473340c57f019a41eef601e85f7d19",
5
5
  "meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
6
6
  "types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
7
7
  "rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
@@ -17,5 +17,5 @@
17
17
  "skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
18
18
  "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
19
19
  },
20
- "composite": "4ab7aac826621cd338e16b85a2d8be23007185e49d3e71aef7ad3e533e9560ec"
20
+ "composite": "a3a4e7ab3c426cc204980b2da980ab42c133a1c5beea744977bea588baa4b415"
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.52.2",
3
+ "version": "0.53.1",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain",
6
6
  "specVersion": "v0.3.0"
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAwB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aA8J5B,CAAC"}
1
+ {"version":3,"file":"plugin.d.ts","sourceRoot":"","sources":["../src/plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAiC,MAAM,0BAA0B,CAAC;AAyB7F;;;;GAIG;AACH,eAAO,MAAM,cAAc,EAAE,aAyK5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.52.2",
3
+ "version": "0.53.1",
4
4
  "type": "module",
5
5
  "description": "Fountain lexicon for chant — sandboxed agent environments, vaults, and agents as typed estate",
6
6
  "license": "Apache-2.0",
@@ -50,7 +50,7 @@
50
50
  "bundle": "tsx src/package-cli.ts"
51
51
  },
52
52
  "peerDependencies": {
53
- "@intentius/chant": "^0.52.2",
53
+ "@intentius/chant": "^0.53.1",
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "devDependencies": {
@@ -0,0 +1,237 @@
1
+ /**
2
+ * fountain deep-observation noise rules (#1217).
3
+ *
4
+ * Plain data, imported statically by `plugin.ts`, because core applies the
5
+ * identical rules to the *declared* property tree — which no reader ever
6
+ * touches — before it diffs. Burying them inside the read would normalize the
7
+ * two sides differently and report everything as drift.
8
+ *
9
+ * ## Why a static table and not an ownership walk
10
+ *
11
+ * Kubernetes records `managedFields`, so the k8s row can subtract whatever a
12
+ * controller owns. A fountain REST payload never says who wrote a field, so
13
+ * this row follows the GCP precedent (`lexicons/gcp/src/deep-observe-hooks.ts`)
14
+ * — a hand-maintained table naming what the server populates. The cost is
15
+ * explicit: a server-set field nobody has listed reads as drift until it is
16
+ * listed. That is visible and fixable; an over-broad rule silently hides real
17
+ * drift, which is the worse of the two failures.
18
+ *
19
+ * Every value below is read off fountain's own Ecto schemas (`Environment`,
20
+ * `Vault`, `Agent`) and the JSON views that render them, not guessed from a
21
+ * sample payload.
22
+ *
23
+ * ## Secrets
24
+ *
25
+ * chant authors an environment's or vault's `secrets` as an ordered
26
+ * `{key, value}[]`, so the declared node holds real secret material. Core's
27
+ * key-name mask (`SENSITIVE_KEY_PATTERNS` matches `secrets`) collapses the
28
+ * whole node to `[REDACTED]` on both trees before any diff sees it, which is
29
+ * the correct outcome and also the limit of what this row can report: presence
30
+ * — an environment that declares no secrets and has some, or the reverse —
31
+ * never the key set, and never a value. Making the key set diffable needs
32
+ * fountain#148's reference model, where secrets stop being inline values.
33
+ *
34
+ * {@link fountainDeepNormalizationHooks.mask} adds one narrow rule on top:
35
+ * a string matching a known credential shape is collapsed wherever it appears,
36
+ * whatever the key is called. FTN001/FTN012 already refuse those at lint time;
37
+ * this is the backstop for a value that reached the live instance some other
38
+ * way, so a drift row can never print one.
39
+ */
40
+
41
+ import type {
42
+ DeepArrayElement,
43
+ DeepNode,
44
+ DeepNormalizationHooks,
45
+ } from "@intentius/chant/deep-observation";
46
+ import { SERVER_FIELDS } from "./import/parser";
47
+
48
+ export const ENVIRONMENT_TYPE = "Fountain::V1::Environment";
49
+ export const VAULT_TYPE = "Fountain::V1::Vault";
50
+ export const AGENT_TYPE = "Fountain::V1::Agent";
51
+
52
+ /**
53
+ * Top-level payload fields fountain writes and a caller cannot: the primary
54
+ * key, the `timestamps()` pair, the owning user, the virtual `*_count` rollups
55
+ * the `*_with_counts` reads attach, the avatar's derived media type, and `acp`
56
+ * (computed per request from the runtime, never stored).
57
+ *
58
+ * Pruned on BOTH sides and regardless of what source declared, since a user who
59
+ * writes one is writing something the API overwrites anyway.
60
+ *
61
+ * Matched on the whole pattern rather than its last segment: `name` and `id`
62
+ * also occur *inside* `skills[]` and `repositories[]`, where they are authored
63
+ * configuration. `SERVER_FIELDS` is shared with the import/export path so the
64
+ * two cannot disagree about what a caller may author.
65
+ */
66
+ export const FOUNTAIN_SERVER_FIELDS: ReadonlySet<string> = new Set([
67
+ ...SERVER_FIELDS,
68
+ "secret_count",
69
+ "agent_count",
70
+ "acp",
71
+ ]);
72
+
73
+ /**
74
+ * Per-kind values fountain fills in when the request omits them, keyed by the
75
+ * index-erased pattern from the tree root.
76
+ *
77
+ * Noise only where **source never declared the property** — the
78
+ * `counterpart === "absent"` gate below. A default somebody wrote out
79
+ * explicitly is a fact worth diffing, and a later change away from it has to
80
+ * stay reportable.
81
+ *
82
+ * Straight off the Ecto `schema` blocks. `networking_type: "unrestricted"` is
83
+ * the one worth arguing about: subtracting it means an environment that never
84
+ * states its networking posture does not report one. That case is FTN010's, at
85
+ * lint time, where it is a build finding rather than drift; and the case this
86
+ * row exists for — a reviewed `limited` environment flipped to `unrestricted`
87
+ * in the UI — has a declared counterpart, so the gate keeps it and it reports
88
+ * as `changed`.
89
+ */
90
+ export const FOUNTAIN_DEFAULTS: Readonly<Record<string, Readonly<Record<string, unknown>>>> = {
91
+ [ENVIRONMENT_TYPE]: {
92
+ setup_script: "",
93
+ networking_type: "unrestricted",
94
+ repositories: [],
95
+ },
96
+ [VAULT_TYPE]: {
97
+ description: "",
98
+ },
99
+ [AGENT_TYPE]: {
100
+ description: "",
101
+ system: "",
102
+ skills: [],
103
+ // ADR 0023. Not on the committed spec snapshot yet, so an instance that
104
+ // predates it simply never emits the field.
105
+ sandbox_mode: "ephemeral",
106
+ },
107
+ };
108
+
109
+ /**
110
+ * Credential shapes FTN001 refuses in source and FTN012 refuses in `env_vars`.
111
+ * Reused here as the mask's own rule so the two lists cannot drift apart in
112
+ * what they call a credential.
113
+ */
114
+ const CREDENTIAL_VALUE_SHAPES: readonly RegExp[] = [
115
+ /^AKIA[0-9A-Z]{16}$/,
116
+ /^(ghp|gho|ghs|ghu)_[A-Za-z0-9]{20,}$/,
117
+ /^github_pat_[A-Za-z0-9_]{20,}$/,
118
+ /^sk-[A-Za-z0-9_-]{20,}$/,
119
+ /^ftn_[A-Za-z0-9]{16,}$/,
120
+ /^xox[baprs]-[A-Za-z0-9-]{10,}$/,
121
+ /^-----BEGIN [A-Z ]*PRIVATE KEY-----/,
122
+ ];
123
+
124
+ /** Key-order-independent equality, so a default written as a container matches whatever order a payload arrives in. */
125
+ function equalsDefault(expected: unknown, actual: unknown): boolean {
126
+ if (expected === actual) return true;
127
+ if (typeof expected !== typeof actual) return false;
128
+ return canonicalJson(expected) === canonicalJson(actual);
129
+ }
130
+
131
+ function canonicalJson(value: unknown): string {
132
+ return (
133
+ JSON.stringify(value, (_k, v: unknown) =>
134
+ v && typeof v === "object" && !Array.isArray(v)
135
+ ? Object.fromEntries(
136
+ Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)),
137
+ )
138
+ : v,
139
+ ) ?? ""
140
+ );
141
+ }
142
+
143
+ /** An `{}` with nothing pruned out of it — fountain's spelling for an unset `:map` column. */
144
+ function isEmptyObject(value: unknown): boolean {
145
+ return typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0;
146
+ }
147
+
148
+ function isEmptyArray(value: unknown): boolean {
149
+ return Array.isArray(value) && value.length === 0;
150
+ }
151
+
152
+ /** The final segment of an index-erased pattern (`skills[].name` -> `name`). */
153
+ function lastSegment(pattern: string): string {
154
+ const withoutIndex = pattern.replace(/\[\]$/, "");
155
+ const dot = withoutIndex.lastIndexOf(".");
156
+ return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
157
+ }
158
+
159
+ export const fountainDeepNormalizationHooks: DeepNormalizationHooks = {
160
+ /**
161
+ * A credential-shaped string never reaches a diff row, a log line or a
162
+ * snapshot. Collapsed on both trees, so a value that is the same on both
163
+ * still classifies as unchanged; a changed one classifies as changed without
164
+ * either side being printed. Same contract as the k8s row's Secret mask
165
+ * (#1365 decision 6): presence and paths, never values.
166
+ */
167
+ mask(node: DeepNode): boolean {
168
+ return typeof node.value === "string" && CREDENTIAL_VALUE_SHAPES.some((p) => p.test(node.value as string));
169
+ },
170
+
171
+ prune(node: DeepNode): boolean {
172
+ // Server-written, on either side, declared or not.
173
+ if (FOUNTAIN_SERVER_FIELDS.has(node.pattern)) return true;
174
+
175
+ // An authored-but-empty `secrets` list is not a fountain state: there is no
176
+ // sub-resource to read back for it, so leaving it on the declared side
177
+ // would report `absent` on every clean read.
178
+ if (lastSegment(node.pattern) === "secrets" && isEmptyArray(node.value)) return true;
179
+
180
+ // Below here: values fountain populated that source never asked for.
181
+ // `counterpart` is a tri-state and only `absent` licenses subtraction.
182
+ if (node.side !== "live" || node.counterpart !== "absent") return false;
183
+
184
+ // `name` is the key the read looked the resource up by, so it can never
185
+ // disagree with the declaration; it is undeclared only when the serializer
186
+ // fell back to the chant export name, which is chant reading back its own
187
+ // choice rather than drift.
188
+ if (node.pattern === "name") return true;
189
+
190
+ // fountain's spellings for "never configured": a nullable column and an
191
+ // unset `:map`. `[]` is deliberately NOT in this rule — an empty
192
+ // `allowed_vault_ids` means "no vault may attach", which is a posture
193
+ // somebody chose, not an absence. The empty-list defaults that ARE server
194
+ // defaults (`skills`, `repositories`) are named per kind below.
195
+ if (node.value === null || isEmptyObject(node.value)) return true;
196
+
197
+ const defaults = FOUNTAIN_DEFAULTS[node.entityType];
198
+ if (!defaults || !Object.prototype.hasOwnProperty.call(defaults, node.pattern)) return false;
199
+ return equalsDefault(defaults[node.pattern], node.value);
200
+ },
201
+
202
+ /**
203
+ * The set-shaped lists. `repositories` and `skills` are keyed by their own
204
+ * identity so one added entry does not rename every entry after it in the
205
+ * flattened diff; the id/host lists are keyed by the element itself.
206
+ *
207
+ * Nothing else is reordered. An array whose elements do not all yield a key
208
+ * keeps the order the payload arrived in, which is the right answer wherever
209
+ * order might carry meaning.
210
+ */
211
+ orderKey(element: DeepArrayElement): string | undefined {
212
+ const name = lastSegment(element.pattern);
213
+ const el = element.element;
214
+
215
+ if (name === "allowed_vault_ids" || name === "allowed_environment_ids" || name === "allowed_hosts") {
216
+ return typeof el === "string" ? el : undefined;
217
+ }
218
+
219
+ if (name === "repositories" && isRecord(el)) {
220
+ return typeof el.mount_path === "string" ? el.mount_path : undefined;
221
+ }
222
+
223
+ if (name === "skills" && isRecord(el)) {
224
+ // An inline entry is identified by its name, a github entry by its repo.
225
+ // Exactly one of the two is set — the server's own changeset enforces it.
226
+ if (typeof el.name === "string") return el.name;
227
+ if (typeof el.source === "string") return el.source;
228
+ return undefined;
229
+ }
230
+
231
+ return undefined;
232
+ },
233
+ };
234
+
235
+ function isRecord(value: unknown): value is Record<string, unknown> {
236
+ return typeof value === "object" && value !== null && !Array.isArray(value);
237
+ }
@@ -0,0 +1,503 @@
1
+ /**
2
+ * fountain deep observation (#1217).
3
+ *
4
+ * The transport is the only thing mocked — a `FountainHttp` routed by
5
+ * `METHOD /path`, the same seam `describe-resources.test.ts` drives — so
6
+ * nothing here opens a socket. The drift assertions run through core's own
7
+ * `diffDeepObservation`, with the lexicon's real hooks on both trees, because
8
+ * the question a noise table has to answer is not "what did the reader return"
9
+ * but "what does a clean apply report".
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+ import { diffDeepObservation, type DeclaredEntities } from "@intentius/chant/lifecycle/deep-observe";
14
+ import { normalizeDeepObservation, MASKED } from "@intentius/chant/deep-observation";
15
+ import { Environment } from "./generated/index";
16
+ import { observeResourcesDeepFountain, type FountainDeepObserveOptions } from "./deep-observe";
17
+ import { fountainDeepNormalizationHooks } from "./deep-observe-hooks";
18
+ import { fountainPlugin } from "./plugin";
19
+ import type { FountainHttp } from "./op/activities/fountain-apply";
20
+
21
+ const ENV = "Fountain::V1::Environment";
22
+ const VAULT = "Fountain::V1::Vault";
23
+ const AGENT = "Fountain::V1::Agent";
24
+
25
+ const STAMPS = { inserted_at: "2026-08-01T00:00:00Z", updated_at: "2026-08-02T00:00:00Z" };
26
+
27
+ /** A live Environment record as fountain's own JSON view renders it. */
28
+ function liveEnvironment(overrides: Record<string, unknown> = {}): Record<string, unknown> {
29
+ return {
30
+ id: "env-1",
31
+ name: "concierge-env",
32
+ packages: {},
33
+ env_vars: {},
34
+ setup_script: "",
35
+ networking_type: "limited",
36
+ networking_config: { allowed_hosts: ["api.github.com"] },
37
+ repositories: [],
38
+ metadata: { "managed-by": "chant" },
39
+ secret_count: 0,
40
+ agent_count: 1,
41
+ ...STAMPS,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ function liveVault(overrides: Record<string, unknown> = {}): Record<string, unknown> {
47
+ return {
48
+ id: "vault-1",
49
+ name: "ops-vault",
50
+ description: "",
51
+ metadata: { "managed-by": "chant" },
52
+ secret_count: 0,
53
+ ...STAMPS,
54
+ ...overrides,
55
+ };
56
+ }
57
+
58
+ function liveAgent(overrides: Record<string, unknown> = {}): Record<string, unknown> {
59
+ return {
60
+ id: "agent-1",
61
+ name: "researcher",
62
+ description: "",
63
+ system: "You research things.",
64
+ model: "anthropic/claude-sonnet-4-6",
65
+ runtime: "claude",
66
+ acp: true,
67
+ sandbox_provider: null,
68
+ sandbox_mode: "ephemeral",
69
+ environment_id: "env-1",
70
+ skills: [{ source: "acme/skills", ref: "v1.2.0" }],
71
+ mcp_servers: {},
72
+ metadata: { "managed-by": "chant" },
73
+ allowed_vault_ids: [],
74
+ allowed_environment_ids: null,
75
+ permission_policy: {},
76
+ conversation_count: 3,
77
+ avatar_media_type: null,
78
+ ...STAMPS,
79
+ ...overrides,
80
+ };
81
+ }
82
+
83
+ interface Route {
84
+ status: number;
85
+ json?: unknown;
86
+ }
87
+
88
+ function routed(routes: Record<string, Route>): FountainHttp {
89
+ return async (method, path) => {
90
+ const hit = routes[`${method} ${path}`];
91
+ if (!hit) throw new Error(`unrouted: ${method} ${path}`);
92
+ return { status: hit.status, json: hit.json ?? null };
93
+ };
94
+ }
95
+
96
+ /** The default estate: one environment, one vault, one agent, no secrets. */
97
+ function estate(overrides: Record<string, Route> = {}): FountainHttp {
98
+ return routed({
99
+ "GET /api/environments": { status: 200, json: { data: [liveEnvironment()] } },
100
+ "GET /api/vaults": { status: 200, json: { data: [liveVault()] } },
101
+ "GET /api/agents": { status: 200, json: { data: [liveAgent()] } },
102
+ "GET /api/environments/env-1/secrets": { status: 200, json: { data: [] } },
103
+ "GET /api/vaults/vault-1/secrets": { status: 200, json: { data: [] } },
104
+ ...overrides,
105
+ });
106
+ }
107
+
108
+ function declared(defs: Record<string, { entityType: string; props: Record<string, unknown> }>): DeclaredEntities {
109
+ return new Map(Object.entries(defs));
110
+ }
111
+
112
+ function options(entities: DeclaredEntities, extra?: Partial<FountainDeepObserveOptions>): FountainDeepObserveOptions {
113
+ return {
114
+ environment: "local",
115
+ buildOutput: "",
116
+ entityNames: [...entities.keys()],
117
+ entities,
118
+ ...extra,
119
+ };
120
+ }
121
+
122
+ /** Read live, then diff against the declaration with the lexicon's own hooks. */
123
+ async function drift(entities: DeclaredEntities, http: FountainHttp, extra?: Partial<FountainDeepObserveOptions>) {
124
+ const live = normalizeDeepObservation(await observeResourcesDeepFountain(options(entities, extra), http));
125
+ return { live, diff: diffDeepObservation(entities, live, fountainDeepNormalizationHooks) };
126
+ }
127
+
128
+ // The declaration the estate above was applied from. `environment` is the
129
+ // typed reference a chant project writes, not the id fountain resolved it to.
130
+ const conciergeEnvironment = new Environment({
131
+ name: "concierge-env",
132
+ networking_type: "limited",
133
+ networking_config: { allowed_hosts: ["api.github.com"] },
134
+ metadata: { "managed-by": "chant" },
135
+ });
136
+
137
+ function conciergeDeclaration(): DeclaredEntities {
138
+ return declared({
139
+ conciergeEnv: {
140
+ entityType: ENV,
141
+ props: {
142
+ name: "concierge-env",
143
+ networking_type: "limited",
144
+ networking_config: { allowed_hosts: ["api.github.com"] },
145
+ metadata: { "managed-by": "chant" },
146
+ },
147
+ },
148
+ opsVault: {
149
+ entityType: VAULT,
150
+ props: { name: "ops-vault", metadata: { "managed-by": "chant" } },
151
+ },
152
+ researcher: {
153
+ entityType: AGENT,
154
+ props: {
155
+ name: "researcher",
156
+ model: "anthropic/claude-sonnet-4-6",
157
+ runtime: "claude",
158
+ system: "You research things.",
159
+ environment: conciergeEnvironment,
160
+ skills: [{ source: "acme/skills", ref: "v1.2.0" }],
161
+ allowed_vault_ids: [],
162
+ metadata: { "managed-by": "chant" },
163
+ },
164
+ },
165
+ });
166
+ }
167
+
168
+ describe("a clean apply reports nothing", () => {
169
+ it("every server-populated field and every untouched default is subtracted", async () => {
170
+ const { diff } = await drift(conciergeDeclaration(), estate());
171
+
172
+ expect(diff.drifted).toEqual([]);
173
+ expect(diff.unobserved).toEqual([]);
174
+ expect(diff.unchanged.sort()).toEqual(["conciergeEnv", "opsVault", "researcher"]);
175
+ });
176
+
177
+ it("the returned tree carries no ids or timestamps", async () => {
178
+ const { live } = await drift(conciergeDeclaration(), estate());
179
+
180
+ for (const observed of Object.values(live.resources)) {
181
+ for (const key of ["id", "inserted_at", "updated_at", "secret_count", "agent_count", "acp", "conversation_count"]) {
182
+ expect(Object.keys(observed.properties)).not.toContain(key);
183
+ }
184
+ }
185
+ // The physical id is reported on the envelope, where it belongs.
186
+ expect(live.resources.conciergeEnv.physicalId).toBe("env-1");
187
+ expect(live.resources.researcher.physicalId).toBe("agent-1");
188
+ });
189
+
190
+ it("a reordered repository list is not drift", async () => {
191
+ const entities = declared({
192
+ env: {
193
+ entityType: ENV,
194
+ props: {
195
+ name: "concierge-env",
196
+ networking_type: "limited",
197
+ metadata: { "managed-by": "chant" },
198
+ repositories: [
199
+ { url: "https://example.com/a.git", mount_path: "/a" },
200
+ { url: "https://example.com/b.git", mount_path: "/b" },
201
+ ],
202
+ },
203
+ },
204
+ });
205
+ const http = estate({
206
+ "GET /api/environments": {
207
+ status: 200,
208
+ json: {
209
+ data: [
210
+ liveEnvironment({
211
+ networking_config: {},
212
+ repositories: [
213
+ { url: "https://example.com/b.git", mount_path: "/b" },
214
+ { url: "https://example.com/a.git", mount_path: "/a" },
215
+ ],
216
+ }),
217
+ ],
218
+ },
219
+ },
220
+ });
221
+
222
+ const { diff } = await drift(entities, http);
223
+ expect(diff.drifted).toEqual([]);
224
+ });
225
+ });
226
+
227
+ describe("the drift the design was written for", () => {
228
+ it("a UI flip from limited to unrestricted reports as a changed property", async () => {
229
+ const http = estate({
230
+ "GET /api/environments": {
231
+ status: 200,
232
+ json: { data: [liveEnvironment({ networking_type: "unrestricted", networking_config: {} })] },
233
+ },
234
+ });
235
+
236
+ const { diff } = await drift(conciergeDeclaration(), http);
237
+ const env = diff.drifted.find((d) => d.name === "conciergeEnv");
238
+ expect(env?.changes).toContainEqual(
239
+ expect.objectContaining({ path: "networking_type", kind: "changed", declared: "limited", live: "unrestricted" }),
240
+ );
241
+ });
242
+
243
+ it("an egress allowlist widened in the UI reports the added host", async () => {
244
+ const http = estate({
245
+ "GET /api/environments": {
246
+ status: 200,
247
+ json: {
248
+ data: [liveEnvironment({ networking_config: { allowed_hosts: ["api.github.com", "evil.example.com"] } })],
249
+ },
250
+ },
251
+ });
252
+
253
+ const { diff } = await drift(conciergeDeclaration(), http);
254
+ const env = diff.drifted.find((d) => d.name === "conciergeEnv");
255
+ expect(env?.changes.some((c) => c.kind === "undeclared" && c.live === "evil.example.com")).toBe(true);
256
+ });
257
+
258
+ it("a vault allowlist widened from none to any reports as drift", async () => {
259
+ const http = estate({
260
+ "GET /api/agents": { status: 200, json: { data: [liveAgent({ allowed_vault_ids: null })] } },
261
+ });
262
+
263
+ const { diff } = await drift(conciergeDeclaration(), http);
264
+ const agent = diff.drifted.find((d) => d.name === "researcher");
265
+ // The declared `[]` (no vault may attach) is gone live. `null` is
266
+ // fountain's legacy-permissive state, and it must not be pruned as an
267
+ // unset column when source declared the field.
268
+ expect(agent?.changes).toContainEqual(
269
+ expect.objectContaining({ path: "allowed_vault_ids", kind: "changed", live: null }),
270
+ );
271
+ });
272
+
273
+ it("a skill unpinned from its ref reports the lost pin", async () => {
274
+ const http = estate({
275
+ "GET /api/agents": { status: 200, json: { data: [liveAgent({ skills: [{ source: "acme/skills" }] })] } },
276
+ });
277
+
278
+ const { diff } = await drift(conciergeDeclaration(), http);
279
+ const agent = diff.drifted.find((d) => d.name === "researcher");
280
+ expect(agent?.changes).toContainEqual(
281
+ expect.objectContaining({ kind: "absent", declared: "v1.2.0" }),
282
+ );
283
+ });
284
+ });
285
+
286
+ describe("secrets: presence classifies, values and keys never leave fountain", () => {
287
+ it("a secret added to an environment that declares none reports as undeclared", async () => {
288
+ const http = estate({
289
+ "GET /api/environments/env-1/secrets": {
290
+ status: 200,
291
+ json: { data: [{ id: "s-1", key: "STRIPE_KEY", environment_id: "env-1", ...STAMPS }] },
292
+ },
293
+ });
294
+
295
+ const { live, diff } = await drift(conciergeDeclaration(), http);
296
+ const env = diff.drifted.find((d) => d.name === "conciergeEnv");
297
+ expect(env?.changes).toContainEqual(
298
+ expect.objectContaining({ path: "secrets", kind: "undeclared", live: MASKED }),
299
+ );
300
+
301
+ // Not the value — fountain never returns one — and not the key either:
302
+ // core's key-name mask collapses the whole node on both trees.
303
+ expect(JSON.stringify(live.resources.conciergeEnv.properties)).not.toContain("STRIPE_KEY");
304
+ expect(JSON.stringify(diff.drifted)).not.toContain("STRIPE_KEY");
305
+ });
306
+
307
+ it("declared secrets against live secrets is unchanged, and no value is compared", async () => {
308
+ const entities = declared({
309
+ env: {
310
+ entityType: ENV,
311
+ props: {
312
+ name: "concierge-env",
313
+ networking_type: "limited",
314
+ networking_config: { allowed_hosts: ["api.github.com"] },
315
+ metadata: { "managed-by": "chant" },
316
+ secrets: [{ key: "STRIPE_KEY", value: "${STRIPE_KEY}" }],
317
+ },
318
+ },
319
+ });
320
+ const http = estate({
321
+ "GET /api/environments/env-1/secrets": {
322
+ status: 200,
323
+ json: { data: [{ id: "s-1", key: "STRIPE_KEY", environment_id: "env-1", ...STAMPS }] },
324
+ },
325
+ });
326
+
327
+ const { diff } = await drift(entities, http);
328
+ expect(diff.drifted).toEqual([]);
329
+ expect(diff.unchanged).toEqual(["env"]);
330
+ });
331
+
332
+ it("an authored-but-empty secrets list is not reported absent", async () => {
333
+ const entities = declared({
334
+ env: {
335
+ entityType: ENV,
336
+ props: {
337
+ name: "concierge-env",
338
+ networking_type: "limited",
339
+ networking_config: { allowed_hosts: ["api.github.com"] },
340
+ metadata: { "managed-by": "chant" },
341
+ secrets: [],
342
+ },
343
+ },
344
+ });
345
+
346
+ const { diff } = await drift(entities, estate());
347
+ expect(diff.drifted).toEqual([]);
348
+ });
349
+
350
+ it("a credential-shaped value is masked on both sides", async () => {
351
+ const entities = declared({
352
+ env: {
353
+ entityType: ENV,
354
+ props: {
355
+ name: "concierge-env",
356
+ networking_type: "limited",
357
+ networking_config: { allowed_hosts: ["api.github.com"] },
358
+ metadata: { "managed-by": "chant" },
359
+ env_vars: { DEPLOY_KEY: "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" },
360
+ },
361
+ },
362
+ });
363
+ const http = estate({
364
+ "GET /api/environments": {
365
+ status: 200,
366
+ json: { data: [liveEnvironment({ env_vars: { DEPLOY_KEY: "ghp_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" } })] },
367
+ },
368
+ });
369
+
370
+ const { live, diff } = await drift(entities, http);
371
+ expect((live.resources.env.properties.env_vars as Record<string, unknown>).DEPLOY_KEY).toBe(MASKED);
372
+ // Both sides collapse, so a rotated credential reads as unchanged rather
373
+ // than printing either value.
374
+ expect(diff.drifted).toEqual([]);
375
+ expect(JSON.stringify(diff)).not.toContain("ghp_");
376
+ });
377
+ });
378
+
379
+ describe("the agent's environment reference", () => {
380
+ it("resolves the server-assigned id back to the environment name", async () => {
381
+ const { live } = await drift(conciergeDeclaration(), estate());
382
+ expect(live.resources.researcher.properties.environment).toBe("concierge-env");
383
+ expect(live.resources.researcher.properties.environment_id).toBeUndefined();
384
+ });
385
+
386
+ it("passes the id through where source authored the id itself", async () => {
387
+ const entities = declared({
388
+ researcher: {
389
+ entityType: AGENT,
390
+ props: {
391
+ name: "researcher",
392
+ model: "anthropic/claude-sonnet-4-6",
393
+ runtime: "claude",
394
+ system: "You research things.",
395
+ environment_id: "env-1",
396
+ skills: [{ source: "acme/skills", ref: "v1.2.0" }],
397
+ allowed_vault_ids: [],
398
+ metadata: { "managed-by": "chant" },
399
+ },
400
+ },
401
+ });
402
+
403
+ const { live, diff } = await drift(entities, estate());
404
+ expect(live.resources.researcher.properties.environment_id).toBe("env-1");
405
+ expect(live.resources.researcher.properties.environment).toBeUndefined();
406
+ expect(diff.drifted).toEqual([]);
407
+ });
408
+
409
+ it("an environment attached to an agent that declares none reports as undeclared", async () => {
410
+ const entities = declared({
411
+ researcher: {
412
+ entityType: AGENT,
413
+ props: {
414
+ name: "researcher",
415
+ model: "anthropic/claude-sonnet-4-6",
416
+ runtime: "claude",
417
+ system: "You research things.",
418
+ skills: [{ source: "acme/skills", ref: "v1.2.0" }],
419
+ allowed_vault_ids: [],
420
+ metadata: { "managed-by": "chant" },
421
+ },
422
+ },
423
+ });
424
+
425
+ const { diff } = await drift(entities, estate());
426
+ expect(diff.drifted[0]?.changes).toContainEqual(
427
+ expect.objectContaining({ path: "environment", kind: "undeclared", live: "concierge-env" }),
428
+ );
429
+ });
430
+ });
431
+
432
+ describe("holes are holes, not clean trees", () => {
433
+ it("a missing token reports no-credentials for every entity and observes nothing", async () => {
434
+ const saved = process.env.FOUNTAIN_TOKEN;
435
+ delete process.env.FOUNTAIN_TOKEN;
436
+ try {
437
+ const result = normalizeDeepObservation(
438
+ await observeResourcesDeepFountain(options(conciergeDeclaration())),
439
+ );
440
+ expect(result.resources).toEqual({});
441
+ expect(Object.values(result.unobserved).map((u) => u.reason)).toEqual([
442
+ "no-credentials",
443
+ "no-credentials",
444
+ "no-credentials",
445
+ ]);
446
+ } finally {
447
+ if (saved !== undefined) process.env.FOUNTAIN_TOKEN = saved;
448
+ }
449
+ });
450
+
451
+ it("a failed kind list marks only that kind read-failed", async () => {
452
+ const http = estate({ "GET /api/vaults": { status: 500 } });
453
+ const { live } = await drift(conciergeDeclaration(), http);
454
+
455
+ expect(live.unobserved.opsVault.reason).toBe("read-failed");
456
+ expect(live.resources.opsVault).toBeUndefined();
457
+ expect(live.resources.conciergeEnv).toBeDefined();
458
+ expect(live.resources.researcher).toBeDefined();
459
+ });
460
+
461
+ it("a failed secrets listing makes the whole entity a hole", async () => {
462
+ const http = estate({ "GET /api/environments/env-1/secrets": { status: 503 } });
463
+ const { live } = await drift(conciergeDeclaration(), http);
464
+
465
+ // Reporting the rest of the environment's properties as clean would be a
466
+ // claim that its secrets did not drift, which this read cannot make.
467
+ expect(live.unobserved.conciergeEnv.reason).toBe("read-failed");
468
+ expect(live.resources.conciergeEnv).toBeUndefined();
469
+ });
470
+
471
+ it("an entity absent from the estate is left to the thin read, not double-reported", async () => {
472
+ const entities = declared({ gone: { entityType: VAULT, props: { name: "not-there" } } });
473
+ const { live } = await drift(entities, estate());
474
+
475
+ expect(live.resources.gone).toBeUndefined();
476
+ expect(live.unobserved.gone).toBeUndefined();
477
+ });
478
+
479
+ it("a kind with no reader is unsupported-kind, never an absence", async () => {
480
+ const entities = declared({ chat: { entityType: "Fountain::V1::Conversation", props: {} } });
481
+ const { live } = await drift(entities, estate());
482
+
483
+ expect(live.unobserved.chat.reason).toBe("unsupported-kind");
484
+ });
485
+
486
+ it("owned:true withholds an unmarked resource as filtered", async () => {
487
+ const http = estate({
488
+ "GET /api/environments": { status: 200, json: { data: [liveEnvironment({ metadata: {} })] } },
489
+ });
490
+ const entities = declared({ conciergeEnv: { entityType: ENV, props: { name: "concierge-env" } } });
491
+ const { live } = await drift(entities, http, { owned: true });
492
+
493
+ expect(live.resources.conciergeEnv).toBeUndefined();
494
+ expect(live.unobserved.conciergeEnv.reason).toBe("filtered");
495
+ });
496
+ });
497
+
498
+ describe("plugin wiring", () => {
499
+ it("exposes the reader and the hooks core needs for the declared tree", () => {
500
+ expect(typeof fountainPlugin.observeResourcesDeep).toBe("function");
501
+ expect(fountainPlugin.deepNormalizationHooks).toBe(fountainDeepNormalizationHooks);
502
+ });
503
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * fountain deep observation (#1217) — the fountain row of the deep-observe
3
+ * contract (#1014).
4
+ *
5
+ * `describeResources()` (./describe-resources.ts) answers whether a declared
6
+ * Environment/Vault/Agent exists and hands back its id and timestamps. That
7
+ * misses the drift the design was written for: an environment hand-edited in
8
+ * the fountain UI from `networking_type: limited` to `unrestricted`, an
9
+ * `allowed_vault_ids` allowlist widened, a skill repointed at an unpinned
10
+ * branch, a secret added to a reviewed sandbox. All of it lives one level
11
+ * down, in properties nobody was reading.
12
+ *
13
+ * ## The read is the thin path's read
14
+ *
15
+ * Transport, endpoint and auth are the applier's, unchanged
16
+ * (`FOUNTAIN_ENDPOINT` / `FOUNTAIN_TOKEN`), so plan reads the instance
17
+ * `fountainApply` writes. And the depth is free: fountain's list endpoints
18
+ * render the full record — `GET /api/environments` returns every configuration
19
+ * field the request schema accepts, not a summary — so there is no per-resource
20
+ * follow-up GET the way the AWS row needs Cloud Control on top of
21
+ * `describe-stack-resources`. One list per declared kind, cached, exactly as
22
+ * the thin path does it.
23
+ *
24
+ * ## The payload passes through
25
+ *
26
+ * fountain's JSON views name their fields the same way the request schema does
27
+ * (`networking_type`, `env_vars`, `skills`), so the live tree and the declared
28
+ * tree already speak one vocabulary — the AWS situation, not temporal's. The
29
+ * payload is therefore forwarded as-is and the noise rules
30
+ * (./deep-observe-hooks.ts) do the rest. A field fountain adds in a later
31
+ * release surfaces as `undeclared` until the table names it, which is the
32
+ * deliberate trade: visible and fixable beats silently dropped.
33
+ *
34
+ * One exception, and it is the reference edge. chant declares an agent's
35
+ * environment as a typed reference (`environment`), fountain stores the id it
36
+ * resolved to (`environment_id`). Passing the id through would report
37
+ * `<undeclared> -> <uuid>` on every clean read, so where source did not author
38
+ * `environment_id` itself the id is resolved back to the environment's name and
39
+ * emitted as `environment` — the same translation `exportResources()` does for
40
+ * the import path.
41
+ *
42
+ * ## Secrets: presence, never keys, never values
43
+ *
44
+ * Values are write-only upstream and are never read here at all. The secrets
45
+ * sub-resource is listed (keys and timestamps only) so that an environment or
46
+ * vault which declares no secrets and has some — somebody adding one to a
47
+ * locked-down sandbox — reports as drift. Core's key-name mask collapses the
48
+ * whole `secrets` node on both trees, so what a diff row can say is that
49
+ * secrets exist, not which. See the hooks module for why the key set itself is
50
+ * not expressible until fountain#148 lands.
51
+ *
52
+ * That listing is one extra request per observed Environment and Vault. A
53
+ * fountain tenant holds a handful of each, and the alternative — inferring
54
+ * presence from the newer payload's `secret_count` — would silently report
55
+ * "no secrets" against any instance predating that field.
56
+ */
57
+
58
+ import type {
59
+ DeepObservationResult,
60
+ DeepResourceObservation,
61
+ UnobservedEntity,
62
+ } from "@intentius/chant/lexicon";
63
+ import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
64
+ import { unobservedAll } from "@intentius/chant/observation";
65
+ import {
66
+ resolveEndpoint,
67
+ defaultFountainHttp,
68
+ isChantOwned,
69
+ OWNERSHIP_KEY,
70
+ OWNERSHIP_VALUE,
71
+ type FountainHttp,
72
+ } from "./op/activities/fountain-apply";
73
+ import {
74
+ fountainDeepNormalizationHooks,
75
+ ENVIRONMENT_TYPE,
76
+ VAULT_TYPE,
77
+ AGENT_TYPE,
78
+ } from "./deep-observe-hooks";
79
+
80
+ // Re-exported so a dynamic importer of this module gets the reader and its
81
+ // hooks from one place. `plugin.ts` imports the hooks separately and
82
+ // statically, because core normalizes the declared tree with them whether or
83
+ // not a live read ever happens.
84
+ export { fountainDeepNormalizationHooks };
85
+
86
+ const KIND_PATHS: Record<string, string> = {
87
+ [ENVIRONMENT_TYPE]: "environments",
88
+ [VAULT_TYPE]: "vaults",
89
+ [AGENT_TYPE]: "agents",
90
+ };
91
+
92
+ /** Kinds whose secrets live in a sub-resource rather than the record itself. */
93
+ const SECRET_BEARING: ReadonlySet<string> = new Set([ENVIRONMENT_TYPE, VAULT_TYPE]);
94
+
95
+ export interface FountainDeepObserveOptions {
96
+ environment: string;
97
+ buildOutput?: string;
98
+ entityNames: string[];
99
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
100
+ stack?: string;
101
+ /** Restrict to resources carrying the `managed-by: chant` marker. */
102
+ owned?: boolean;
103
+ /** Endpoint override (tests). Defaults to resolveEndpoint(). */
104
+ endpoint?: string;
105
+ }
106
+
107
+ /** The fields this reader reads by name off a live record. Everything else passes through. */
108
+ interface LiveRecord extends Record<string, unknown> {
109
+ id: string;
110
+ name: string;
111
+ metadata?: Record<string, unknown>;
112
+ environment_id?: string | null;
113
+ }
114
+
115
+ /** One list per kind, shared by every entity of that kind — including a failure. */
116
+ class KindLists {
117
+ private readonly lists = new Map<string, Promise<Map<string, LiveRecord>>>();
118
+
119
+ constructor(private readonly http: FountainHttp) {}
120
+
121
+ byName(entityType: string): Promise<Map<string, LiveRecord>> {
122
+ const cached = this.lists.get(entityType);
123
+ if (cached) return cached;
124
+
125
+ const path = KIND_PATHS[entityType];
126
+ const pending = (async () => {
127
+ const { status, json } = await this.http("GET", `/api/${path}`);
128
+ if (status !== 200) throw new Error(`list ${path} returned ${status}`);
129
+ const data = (json as { data?: LiveRecord[] })?.data ?? [];
130
+ return new Map(data.map((r) => [r.name, r]));
131
+ })();
132
+
133
+ this.lists.set(entityType, pending);
134
+ return pending;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * The keys of a resource's secrets, sorted. Never the values — the API does not
140
+ * return them and this never asks. Returns `undefined` when there are none, so
141
+ * an environment without secrets carries no `secrets` path at all: an empty
142
+ * list is itself a value, and reporting one against a declaration that has none
143
+ * would be noise wearing the shape of drift.
144
+ */
145
+ async function secretKeys(
146
+ http: FountainHttp,
147
+ kindPath: string,
148
+ id: string,
149
+ ): Promise<Array<{ key: string }> | undefined> {
150
+ const { status, json } = await http("GET", `/api/${kindPath}/${id}/secrets`);
151
+ if (status !== 200) throw new Error(`list ${kindPath}/${id}/secrets returned ${status}`);
152
+ const data = (json as { data?: Array<{ key?: unknown }> })?.data ?? [];
153
+ const keys = data
154
+ .map((s) => s.key)
155
+ .filter((k): k is string => typeof k === "string")
156
+ .sort();
157
+ return keys.length > 0 ? keys.map((key) => ({ key })) : undefined;
158
+ }
159
+
160
+ /**
161
+ * The live property tree for an agent, with the reference edge put back into
162
+ * the vocabulary source writes it in (see the module doc).
163
+ */
164
+ function agentProperties(
165
+ record: LiveRecord,
166
+ declared: Record<string, unknown>,
167
+ environmentNameById: Map<string, string>,
168
+ ): Record<string, unknown> {
169
+ const tree: Record<string, unknown> = { ...record };
170
+ if (declared.environment_id !== undefined) return tree;
171
+
172
+ const id = record.environment_id;
173
+ if (typeof id !== "string") return tree;
174
+ const name = environmentNameById.get(id);
175
+ // An id with no environment behind it should not exist (the column carries a
176
+ // foreign key), but if it does, the raw id is the honest thing to report.
177
+ if (!name) return tree;
178
+
179
+ delete tree.environment_id;
180
+ tree.environment = name;
181
+ return tree;
182
+ }
183
+
184
+ /**
185
+ * Read the live property tree for each declared fountain entity.
186
+ *
187
+ * `http` is injectable for tests; the default reuses the applier's fetch client
188
+ * (bearer token from FOUNTAIN_TOKEN). A missing token is the whole-lexicon
189
+ * failure the thin path already names — every declared entity NOT-OBSERVED with
190
+ * `no-credentials`, never an empty tree, which would read as "nothing drifted".
191
+ */
192
+ export async function observeResourcesDeepFountain(
193
+ options: FountainDeepObserveOptions,
194
+ injected?: FountainHttp,
195
+ ): Promise<DeepObservationResult> {
196
+ const names = [...options.entities.keys()];
197
+
198
+ let http = injected;
199
+ if (!http) {
200
+ const token = process.env.FOUNTAIN_TOKEN;
201
+ if (!token) {
202
+ return deepObservation(
203
+ {},
204
+ unobservedAll(
205
+ names,
206
+ "no-credentials",
207
+ "FOUNTAIN_TOKEN is not set — cannot read live fountain state",
208
+ options.entities,
209
+ ),
210
+ );
211
+ }
212
+ http = defaultFountainHttp(resolveEndpoint({ endpoint: options.endpoint }), token);
213
+ }
214
+
215
+ const lists = new KindLists(http);
216
+ const resources: Record<string, DeepResourceObservation> = {};
217
+ const unobserved: Record<string, UnobservedEntity> = {};
218
+
219
+ // Built lazily and only for agents, so a project that declares no agent never
220
+ // pays for the environments list it would not otherwise read.
221
+ let environmentNameById: Map<string, string> | undefined;
222
+ const environmentNames = async (): Promise<Map<string, string>> => {
223
+ if (!environmentNameById) {
224
+ const byName = await lists.byName(ENVIRONMENT_TYPE);
225
+ environmentNameById = new Map([...byName.values()].map((r) => [r.id, r.name]));
226
+ }
227
+ return environmentNameById;
228
+ };
229
+
230
+ for (const [entityName, { entityType, props }] of options.entities) {
231
+ if (!(entityType in KIND_PATHS)) {
232
+ unobserved[entityName] = {
233
+ type: entityType,
234
+ reason: "unsupported-kind",
235
+ detail: `no fountain deep reader for ${entityType}`,
236
+ };
237
+ continue;
238
+ }
239
+
240
+ const resourceName = typeof props.name === "string" ? props.name : entityName;
241
+
242
+ try {
243
+ const byName = await lists.byName(entityType);
244
+ const record = byName.get(resourceName);
245
+
246
+ // Not deployed. The thin read already reports the absence (#1089);
247
+ // restating it here as a property hole would turn one finding into two.
248
+ if (!record) continue;
249
+
250
+ if (options.owned && !isChantOwned(record)) {
251
+ unobserved[entityName] = {
252
+ type: entityType,
253
+ reason: "filtered",
254
+ detail: `"${resourceName}" exists but does not carry the ${OWNERSHIP_KEY}: ${OWNERSHIP_VALUE} marker`,
255
+ };
256
+ continue;
257
+ }
258
+
259
+ let tree: Record<string, unknown>;
260
+ if (entityType === AGENT_TYPE) {
261
+ tree = agentProperties(record, props, await environmentNames());
262
+ } else {
263
+ tree = { ...record };
264
+ }
265
+
266
+ if (SECRET_BEARING.has(entityType)) {
267
+ const secrets = await secretKeys(http, KIND_PATHS[entityType], record.id);
268
+ if (secrets) tree.secrets = secrets;
269
+ }
270
+
271
+ resources[entityName] = {
272
+ type: entityType,
273
+ physicalId: record.id,
274
+ properties: normalizeDeepProperties(tree, {
275
+ entityType,
276
+ side: "live",
277
+ hooks: fountainDeepNormalizationHooks,
278
+ }),
279
+ };
280
+ } catch (err) {
281
+ // Per-entity, with the reason. A partial property surface — a failed
282
+ // secrets listing, a failed kind list — must never arrive as a clean
283
+ // tree, because a clean tree is a claim that nothing drifted.
284
+ unobserved[entityName] = {
285
+ type: entityType,
286
+ reason: "read-failed",
287
+ detail: err instanceof Error ? err.message : String(err),
288
+ };
289
+ }
290
+ }
291
+
292
+ return deepObservation(resources, unobserved);
293
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,16 @@ export { fountainPlugin } from "./plugin";
4
4
  // Serializer
5
5
  export { fountainSerializer } from "./serializer";
6
6
 
7
+ // Deep observation (#1217) — the reader plus the noise rules it shares with
8
+ // core's normalization pass.
9
+ export { observeResourcesDeepFountain } from "./deep-observe";
10
+ export type { FountainDeepObserveOptions } from "./deep-observe";
11
+ export {
12
+ fountainDeepNormalizationHooks,
13
+ FOUNTAIN_SERVER_FIELDS,
14
+ FOUNTAIN_DEFAULTS,
15
+ } from "./deep-observe-hooks";
16
+
7
17
  // Generated resources — Environment, Vault, Agent, and property types.
8
18
  export * from "./generated/index";
9
19
 
package/src/plugin.ts CHANGED
@@ -21,6 +21,7 @@ import { FountainGenerator } from "./import/generator";
21
21
  import { sitesToTemplateIR } from "./import/local-agents";
22
22
  import { completions } from "./lsp/completions";
23
23
  import { hover } from "./lsp/hover";
24
+ import { fountainDeepNormalizationHooks } from "./deep-observe-hooks";
24
25
 
25
26
  /**
26
27
  * fountain lexicon plugin.
@@ -171,6 +172,17 @@ export const fountainPlugin: LexiconPlugin = {
171
172
  return describeResources(options);
172
173
  },
173
174
 
175
+ // Dynamic, never static: `chant build` must not resolve the live transport
176
+ // just to synthesize a manifest. The hooks below are plain data and are
177
+ // imported statically, because core normalizes the *declared* tree with them
178
+ // whether or not a live read ever happens.
179
+ async observeResourcesDeep(options) {
180
+ const { observeResourcesDeepFountain } = await import("./deep-observe");
181
+ return observeResourcesDeepFountain(options);
182
+ },
183
+
184
+ deepNormalizationHooks: fountainDeepNormalizationHooks,
185
+
174
186
  referenceCatalog: fountainReferenceCatalog,
175
187
 
176
188
  completionProvider(ctx: CompletionContext) {