@intentius/chant-lexicon-fountain 0.33.0 → 0.34.0

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.
package/README.md CHANGED
@@ -9,8 +9,8 @@ resources: `Environment` (sandbox baseline), `Vault` (env-var overrides),
9
9
  start them with the `fountainRun` op.
10
10
 
11
11
  `chant build` serializes to fountain's own manifest YAML (`fountain apply -f`
12
- accepts it verbatim) plus a `fountain-plan.json` sidecar that `fountainApply`
13
- reconciles against the API directly.
12
+ accepts it verbatim) the same YAML `fountainApply` sends to fountain's bulk
13
+ `POST /api/apply` endpoint in one request.
14
14
 
15
15
  ```ts
16
16
  import { Environment, Agent } from "@intentius/chant-lexicon-fountain";
@@ -52,7 +52,7 @@ added, plus the request schemas deliberately left unmodeled.
52
52
  ## Project structure
53
53
 
54
54
  - `src/plugin.ts` — LexiconPlugin with all lifecycle methods
55
- - `src/serializer.ts` — manifest YAML + `fountain-plan.json` output
55
+ - `src/serializer.ts` — manifest YAML output
56
56
  - `src/codegen/`, `src/spec/` — generation pipeline and spec fetch/parse
57
57
  - `src/lint/rules/`, `src/lint/post-synth/` — FTN001 (AST) and FTN010–017
58
58
  - `src/op/activities/` — `fountainApply` (reconciler) and `fountainRun`
@@ -1 +1 @@
1
- {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAiNH;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDjF"}
1
+ {"version":3,"file":"docs.d.ts","sourceRoot":"","sources":["../../src/codegen/docs.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAkNH;;GAEG;AACH,wBAAsB,YAAY,CAAC,OAAO,CAAC,EAAE;IAAE,OAAO,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAyDjF"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "algorithm": "sha256",
3
3
  "artifacts": {
4
- "manifest.json": "757017e21fc2f8173e8da69305a7f4311ccd8830785f9e86c552e9991a5a804e",
4
+ "manifest.json": "1f8298cb243abce8502bb115152fd11c88547289737f0c2fa0df7b8129639f53",
5
5
  "meta.json": "6666b7a77db9210a329219c6a5e107e5ae8794a34542f7310ce16d9bf95c64f5",
6
6
  "types/index.d.ts": "1dfdcba184fffcf464dac7d1d71ef7abed2ae02a8d638c0de50e0b0317b7eb01",
7
7
  "rules/ftn001-no-secret-literals.ts": "897a4ce1ec790b3c1540d32892603bd33ff4bf30eb6ba4cd2565dee356d4962e",
@@ -13,9 +13,9 @@
13
13
  "rules/ftn015-mcp-secret-env-substitution.ts": "88dc87c8f403a2fe48ee43bc4a4678d95273dba810135c8e74750f1bd62d2564",
14
14
  "rules/ftn016-runtime-model-valid.ts": "b4aa86267a411678fe6a8ae3556fd9b34cf1c15aaecfe99790216d529a494f44",
15
15
  "rules/ftn017-unique-resource-names.ts": "866f3f385083f96b6e8a18fa6f5624bab43752c5d22e7291e95835e62745d26c",
16
- "skills/chant-fountain.md": "71c077dbeb40a95cb3a341d99a54eaefc321e07ba789c8b92574f385f9983f3f",
17
- "skills/chant-fountain-secrets.md": "6b968e76ed135adde9e5970495beb752e4d9bcf7b02a2a9501fc18b58676d01e",
16
+ "skills/chant-fountain.md": "06dd02d19394fa777c7ad91dc54f684d273a36456a8660d41dc9701e48d955b3",
17
+ "skills/chant-fountain-secrets.md": "27e349a91589510a92e518c7d7824a4a322cab5ef242cf5799373a55cbfcd1cd",
18
18
  "skills/chant-fountain-locked-sandboxes.md": "de82f06cb3a08ba6bf3ae45fb9869e21d6da18b9ebe0fc769da8aebaceea7dd1"
19
19
  },
20
- "composite": "4e29aaf0b03c36a53922b819db6851256b3e5b15f1daa9016d14b96874285ccc"
20
+ "composite": "3321e342144da2914f44f7085c4bccfc85055c16cf8435288283c28019b4149b"
21
21
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fountain",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Fountain"
6
6
  }
@@ -1,35 +1,39 @@
1
1
  /**
2
2
  * fountain native applier.
3
3
  *
4
- * Direct-REST reconciler against fountain's API (the same surface
5
- * `fountain apply` uses, but API-first: create-if-new, update-if-changed
6
- * by name, optional owned-only prune). Input is the serializer's
7
- * `fountain-plan.json` sidecar entity name → { kind, spec } — so no
8
- * YAML parsing happens on the apply side.
4
+ * Compiles the serializer's manifest YAML — the same `apiVersion:
5
+ * fountain.dev/v1` documents `fountain apply -f` accepts into fountain's
6
+ * `POST /api/apply` bulk-apply request and sends it in one call. That
7
+ * endpoint (BinaryBourbon/fountain#151) does the reconciliation server-side:
8
+ * upsert by name, environments -> vaults -> agents, an agent's
9
+ * `spec.environment` name resolved against the manifest or the tenant's
10
+ * existing environments, secrets upserted through the encrypted envelope
11
+ * path. Best-effort per resource — every result is collected before this
12
+ * throws, so one bad resource doesn't hide failures in the rest of the
13
+ * manifest, and a partial apply is never silently reported as clean.
9
14
  *
10
- * Apply order is Environment Vault Agent: agents reference their
11
- * environment by entity name in the plan, resolved to the live id here
12
- * (mirroring the CLI's name→id resolution in apply.go).
15
+ * No id resolution happens here anymore: since the server resolves an
16
+ * agent's `environment` reference by name itself, the manifest's `spec`
17
+ * passes through unmodified except for one shape adjustment — chant's
18
+ * authored `secrets` is an ordered `{key, value}[]`, the wire format wants
19
+ * `{KEY: value}` (see `toApplyPayload`).
13
20
  *
14
- * Secrets: `spec.secrets` entries ({key, value}) are stripped from the
15
- * resource body and upserted through the secrets sub-resource. Values are
16
- * write-only upstream, so this is upsert-always — a changed value cannot
17
- * be detected, only overwritten. (BinaryBourbon/fountain#148's reference
18
- * model would make this a plain diff; until then, upsert-always is the
19
- * honest semantic.)
21
+ * Prune (opt-in, chant-owned only) isn't part of bulk apply, so it still
22
+ * lists each kind's live state and deletes what's absent from the
23
+ * manifest, same as before.
20
24
  *
21
25
  * Endpoint/token resolution: explicit args win, then FOUNTAIN_ENDPOINT /
22
26
  * FOUNTAIN_TOKEN, then the hosted default endpoint.
23
27
  */
24
- export declare const DEFAULT_FOUNTAIN_BASE_URL = "https://founta.inevitable.fyi";
28
+ export declare const DEFAULT_FOUNTAIN_BASE_URL = "https://fountain.inevitable.fyi";
25
29
  /** Ownership marker checked by the owned-only prune. */
26
30
  export declare const OWNERSHIP_KEY = "managed-by";
27
31
  export declare const OWNERSHIP_VALUE = "chant";
28
- export interface PlanEntry {
32
+ export interface ManifestResource {
29
33
  kind: string;
34
+ name: string;
30
35
  spec: Record<string, unknown>;
31
36
  }
32
- export type FountainPlan = Record<string, PlanEntry>;
33
37
  export interface FountainHttp {
34
38
  (method: string, path: string, body?: unknown): Promise<{
35
39
  status: number;
@@ -37,13 +41,13 @@ export interface FountainHttp {
37
41
  }>;
38
42
  }
39
43
  export interface FountainApplyArgs {
40
- /** Path to the serializer's fountain-plan.json. */
41
- planPath?: string;
42
- /** Inline plan content (takes precedence over planPath). */
43
- planContent?: string;
44
+ /** Path to the serializer's compiled fountain manifest YAML. */
45
+ manifestPath?: string;
46
+ /** Inline manifest YAML content (takes precedence over manifestPath). */
47
+ manifestContent?: string;
44
48
  endpoint?: string;
45
49
  token?: string;
46
- /** Delete chant-owned resources absent from the plan. Off by default. */
50
+ /** Delete chant-owned resources absent from the manifest. Off by default. */
47
51
  prune?: boolean;
48
52
  }
49
53
  export interface FountainApplySummary {
@@ -58,15 +62,14 @@ export declare function resolveEndpoint(args?: {
58
62
  export declare function resolveToken(args?: {
59
63
  token?: string;
60
64
  }, env?: NodeJS.ProcessEnv): string;
61
- export declare function parsePlan(content: string): FountainPlan;
62
- /** Split spec.secrets ({key, value}[]) from the resource body. Pure. */
63
- export declare function splitSecrets(spec: Record<string, unknown>): {
64
- body: Record<string, unknown>;
65
- secrets: Array<{
66
- key: string;
67
- value: string;
68
- }>;
69
- };
65
+ /** Parse the serializer's multi-document manifest YAML into apply resources. Pure. */
66
+ export declare function parseManifest(content: string): ManifestResource[];
67
+ /**
68
+ * Fountain's bulk-apply spec takes `secrets` as a `{KEY: value}` map;
69
+ * chant's authored shape is an ordered `{key, value}[]`. Convert only that
70
+ * one field — the rest of spec passes through untouched. Pure.
71
+ */
72
+ export declare function toApplyPayload(spec: Record<string, unknown>): Record<string, unknown>;
70
73
  /** Is a live resource chant-owned (by its metadata marker)? Pure. */
71
74
  export declare function isChantOwned(resource: {
72
75
  metadata?: unknown;
@@ -1 +1 @@
1
- {"version":3,"file":"fountain-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAIH,eAAO,MAAM,yBAAyB,kCAAkC,CAAC;AAEzE,wDAAwD;AACxD,eAAO,MAAM,aAAa,eAAe,CAAC;AAC1C,eAAO,MAAM,eAAe,UAAU,CAAC;AAWvC,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,MAAM,YAAY,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;AAErD,MAAM,WAAW,YAAY;IAC3B,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CAC5F;AAED,MAAM,WAAW,iBAAiB;IAChC,mDAAmD;IACnD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,4DAA4D;IAC5D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yEAAyE;IACzE,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;CACzB;AAID,wBAAgB,eAAe,CAC7B,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,EAChC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,CAGR;AAED,wBAAgB,YAAY,CAC1B,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,EAC7B,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,CAIR;AAED,wBAAgB,SAAS,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,CAEvD;AAED,wEAAwE;AACxE,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC3D,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC9B,OAAO,EAAE,KAAK,CAAC;QAAE,GAAG,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAChD,CAQA;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,QAAQ,EAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAItE;AAID,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAkBjF;AAiBD,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,IAAI,CAAC,EAAE,YAAY,GAClB,OAAO,CAAC,oBAAoB,CAAC,CA8F/B"}
1
+ {"version":3,"file":"fountain-apply.d.ts","sourceRoot":"","sources":["../../../src/op/activities/fountain-apply.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AAKH,eAAO,MAAM,yBAAyB,oCAAoC,CAAC;AAE3E,wDAAwD;AACxD,eAAO,MAAM,aAAa,eAAe,CAAC;AAC1C,eAAO,MAAM,eAAe,UAAU,CAAC;AAavC,MAAM,WAAW,gBAAgB;IAC/B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC/B;AAED,MAAM,WAAW,YAAY;IAC3B,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,OAAO,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,OAAO,CAAA;KAAE,CAAC,CAAC;CAC5F;AAED,MAAM,WAAW,iBAAiB;IAChC,gEAAgE;IAChE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,6EAA6E;IAC7E,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,eAAe,EAAE,MAAM,CAAC;CACzB;AAID,wBAAgB,eAAe,CAC7B,IAAI,GAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAO,EAChC,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,CAGR;AAED,wBAAgB,YAAY,CAC1B,IAAI,GAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAO,EAC7B,GAAG,GAAE,MAAM,CAAC,UAAwB,GACnC,MAAM,CAIR;AAMD,sFAAsF;AACtF,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,gBAAgB,EAAE,CAajE;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAUrF;AAED,qEAAqE;AACrE,wBAAgB,YAAY,CAAC,QAAQ,EAAE;IAAE,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,GAAG,OAAO,CAItE;AAID,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,YAAY,CAkBjF;AA+BD,wBAAsB,aAAa,CACjC,IAAI,EAAE,iBAAiB,EACvB,IAAI,CAAC,EAAE,YAAY,GAClB,OAAO,CAAC,oBAAoB,CAAC,CAyD/B"}
@@ -4,8 +4,8 @@
4
4
  * the native applier (`fountainApply` — direct REST against fountain's
5
5
  * API, no CLI, no state file) and the conversation runner (`fountainRun`).
6
6
  */
7
- export { fountainApply, resolveEndpoint, resolveToken, parsePlan, splitSecrets, isChantOwned, defaultFountainHttp, DEFAULT_FOUNTAIN_BASE_URL, OWNERSHIP_KEY, OWNERSHIP_VALUE, } from "./fountain-apply.js";
8
- export type { FountainApplyArgs, FountainApplySummary, FountainPlan, PlanEntry, FountainHttp, } from "./fountain-apply.js";
7
+ export { fountainApply, resolveEndpoint, resolveToken, parseManifest, toApplyPayload, isChantOwned, defaultFountainHttp, DEFAULT_FOUNTAIN_BASE_URL, OWNERSHIP_KEY, OWNERSHIP_VALUE, } from "./fountain-apply.js";
8
+ export type { FountainApplyArgs, FountainApplySummary, ManifestResource, FountainHttp, } from "./fountain-apply.js";
9
9
  export { fountainRun, resolveAgentId, TERMINAL_STATUSES } from "./fountain-run.js";
10
10
  export type { FountainRunArgs, FountainRunResult } from "./fountain-run.js";
11
11
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,SAAS,EACT,YAAY,EACZ,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,YAAY,EACZ,SAAS,EACT,YAAY,GACb,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAChF,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/op/activities/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AACH,OAAO,EACL,aAAa,EACb,eAAe,EACf,YAAY,EACZ,aAAa,EACb,cAAc,EACd,YAAY,EACZ,mBAAmB,EACnB,yBAAyB,EACzB,aAAa,EACb,eAAe,GAChB,MAAM,kBAAkB,CAAC;AAC1B,YAAY,EACV,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,YAAY,GACb,MAAM,kBAAkB,CAAC;AAE1B,OAAO,EAAE,WAAW,EAAE,cAAc,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC;AAChF,YAAY,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,gBAAgB,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAgC,MAAM,kBAAkB,CAAC;AAsBjF,eAAO,MAAM,kBAAkB,EAAE,UAwDhC,CAAC"}
1
+ {"version":3,"file":"serializer.d.ts","sourceRoot":"","sources":["../src/serializer.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAc,MAAM,kBAAkB,CAAC;AA2B/D,eAAO,MAAM,kBAAkB,EAAE,UAgDhC,CAAC"}
@@ -11,7 +11,7 @@ user-invocable: true
11
11
  Everything materialized into a fountain sandbox must be presumed exfiltrated once untrusted agent code runs. Order of preference:
12
12
 
13
13
  1. **`${VAR}` substitution references** in agent config (MCP server env, system prompts). Resolved at spawn from the merged environment + vault sets. Never a value in source.
14
- 2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` upserts them through the sub-resource; a changed value cannot be detected, only overwritten.
14
+ 2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` sends them inline with the rest of the resource in the bulk apply request, and the server upserts them through the encrypted envelope path; a changed value cannot be detected, only overwritten.
15
15
  3. **`env_vars`** — plaintext config only. FTN012 errors on credential-shaped keys or values here.
16
16
 
17
17
  Never put a literal credential anywhere in a declaration: FTN001 catches known shapes (AWS keys, GitHub/Slack tokens, `sk-`/`ftn_` keys, private key material) at the AST; FTN015 errors on secret-shaped MCP env keys that are not `${VAR}` references.
@@ -10,7 +10,7 @@ user-invocable: true
10
10
 
11
11
  [fountain](https://github.com/BinaryBourbon/fountain) runs coding agents in sandboxed VMs. Its workload layer is three kinds — `Environment` (sandbox baseline), `Vault` (env-var overrides), `Agent` (a runnable agent config) — and this lexicon declares them as typed chant resources. Conversations are runs, not resources: start them with the `fountainRun` op, never declare them.
12
12
 
13
- The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim) plus a `fountain-plan.json` sidecar that `fountainApply` reconciles against the API directly: create-if-new, update-by-name, opt-in owned-only prune keyed on the `managed-by: chant` metadata marker.
13
+ The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim). `fountainApply` sends that same YAML to fountain's bulk `POST /api/apply` endpoint in one request: create-if-new, update-by-name, opt-in owned-only prune keyed on the `managed-by: chant` metadata marker.
14
14
 
15
15
  ## Authoring
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.33.0",
3
+ "version": "0.34.0",
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.33.0",
53
+ "@intentius/chant": "^0.34.0",
54
54
  "typescript": "^5.9.3"
55
55
  },
56
56
  "devDependencies": {
@@ -55,7 +55,7 @@ export const helper = new Agent({
55
55
 
56
56
  \`FOUNTAIN_ENDPOINT\` (defaults to the hosted instance) and \`FOUNTAIN_TOKEN\`. Mint a token via \`POST /api/auth/token\` with email and password, or from the account UI. The same code applies to a local \`mix phx.server\` fountain by pointing \`FOUNTAIN_ENDPOINT\` at it — registration and token mint work headless, so CI needs no browser.`;
57
57
 
58
- const outputFormat = `The fountain lexicon serializes to fountain's own manifest YAML plus a \`fountain-plan.json\` sidecar.
58
+ const outputFormat = `The fountain lexicon serializes to fountain's own manifest YAML nothing else. \`fountainApply\` reads that same YAML, so there is no sidecar to keep in sync with it.
59
59
 
60
60
  ## Manifests
61
61
 
@@ -75,17 +75,17 @@ spec:
75
75
 
76
76
  The output is ejectable — \`fountain apply -f\` accepts it verbatim, so adopting chant here does not trap the manifests behind chant.
77
77
 
78
- ## The plan sidecar
78
+ ## Applying the manifest
79
79
 
80
- \`fountain-plan.json\` is entity name \`{ kind, spec }\`. The \`fountainApply\` activity reconciles from it directly against the REST API, so nothing on the apply side parses YAML. Apply order is Environment → Vault → Agent; an agent's \`environment\` reference carries the entity name and is resolved to the live id at apply.
80
+ \`fountainApply\` parses this same YAML and sends it to fountain's bulk \`POST /api/apply\` endpoint in one request the server reconciles by name, Environment → Vault → Agent, and resolves an agent's \`environment\` reference itself, against the manifest or the tenant's existing environments. See the Ops page for the activity's own behavior (prune, secrets, failure reporting).
81
81
 
82
82
  ## Ownership
83
83
 
84
- Resources carrying \`metadata."managed-by": chant\` are chant-owned. That marker gates the opt-in prune (\`fountainApply\` deletes only owned resources absent from the plan) and the \`--owned\` filter on drift and live export. Set it on every declaration you want reconciled.
84
+ Resources carrying \`metadata."managed-by": chant\` are chant-owned. That marker gates the opt-in prune (\`fountainApply\` deletes only owned resources absent from the manifest) and the \`--owned\` filter on drift and live export. Set it on every declaration you want reconciled.
85
85
 
86
86
  ## Secrets
87
87
 
88
- \`spec.secrets\` entries are split out of the resource body and upserted through the secrets sub-resource. Values are write-only upstream, so this is upsert-always — a changed value cannot be detected, only overwritten.`;
88
+ \`spec.secrets\` is authored as an ordered \`{key, value}[]\`, same as any other typed prop. \`fountainApply\` converts it to the \`{KEY: value}\` map fountain's bulk apply expects on the wire; the server upserts it through the encrypted envelope path. Values are write-only upstream, so this is upsert-always — a changed value cannot be detected, only overwritten.`;
89
89
 
90
90
  const resourcesPage = `The lexicon types the three kinds \`fountain apply\` reconciles — the workload
91
91
  layer of [fountain](https://github.com/BinaryBourbon/fountain). Types are
@@ -166,17 +166,18 @@ const opsPage = `Two op activities ship with the lexicon, resolvable by name via
166
166
 
167
167
  ## fountainApply
168
168
 
169
- The native applier: a direct-REST reconciler over the serializer's \`fountain-plan.json\`.
169
+ The native applier: compiles the serializer's manifest YAML into fountain's bulk \`POST /api/apply\` request and sends it in one call.
170
170
 
171
171
  | Behavior | Detail |
172
172
  |---|---|
173
- | Create / update | By name. A resource in the plan that exists live is updated; one that does not is created. |
174
- | Order | Environment → Vault → Agent, so an agent's environment reference resolves to a live id. |
175
- | Prune | Off by default. With \`prune: true\`, chant-owned resources absent from the plan are deleted, in reverse order. |
176
- | Secrets | Split from the body and upserted through the secrets sub-resource. Upsert-always — values are write-only upstream. |
173
+ | Create / update | By name, reconciled server-side. An agent's \`environment\` reference resolves by name — against the manifest or the tenant's existing environments without a client-side id lookup. |
174
+ | Order | Environment → Vault → Agent, fixed server-side regardless of manifest order. |
175
+ | Prune | Off by default. With \`prune: true\`, chant-owned resources absent from the manifest are deleted, in reverse order — the one thing bulk apply doesn't cover, so this still lists live state per kind. |
176
+ | Secrets | \`spec.secrets\` converts from chant's authored \`{key, value}[]\` to the wire's \`{KEY: value}\` map; the server upserts them through the encrypted envelope path in the same request. Upsert-always — values are write-only upstream. |
177
+ | Failure | Best-effort per resource. Every result is collected before this throws, so one bad resource doesn't hide failures elsewhere in the manifest. |
177
178
 
178
179
  \`\`\`typescript
179
- await fountainApply({ planPath: "build/fountain-plan.json", prune: true });
180
+ await fountainApply({ manifestPath: "build/fountain.yaml", prune: true });
180
181
  \`\`\`
181
182
 
182
183
  Endpoint and token resolution: explicit args win, then \`FOUNTAIN_ENDPOINT\` / \`FOUNTAIN_TOKEN\`, then the hosted default.
@@ -1,12 +1,11 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import {
3
3
  fountainApply,
4
- splitSecrets,
4
+ parseManifest,
5
+ toApplyPayload,
5
6
  isChantOwned,
6
7
  resolveEndpoint,
7
- parsePlan,
8
8
  type FountainHttp,
9
- type FountainPlan,
10
9
  } from "./fountain-apply";
11
10
  import { fountainRun } from "./fountain-run";
12
11
 
@@ -32,26 +31,65 @@ function fakeHttp(routes: Record<string, { status: number; json?: unknown }>): {
32
31
  return { http, calls };
33
32
  }
34
33
 
35
- const EMPTY_LISTS = {
36
- "GET /api/environments": { status: 200, json: { data: [] } },
37
- "GET /api/vaults": { status: 200, json: { data: [] } },
38
- "GET /api/agents": { status: 200, json: { data: [] } },
39
- };
34
+ const MANIFEST = `apiVersion: fountain.dev/v1
35
+ kind: Environment
36
+ metadata:
37
+ name: concierge-env
38
+ spec:
39
+ networking_type: limited
40
+ ---
41
+ apiVersion: fountain.dev/v1
42
+ kind: Agent
43
+ metadata:
44
+ name: researcher
45
+ spec:
46
+ model: a/m
47
+ runtime: claude
48
+ environment: concierge-env
49
+ `;
40
50
 
41
51
  describe("pure helpers", () => {
42
52
  it("resolveEndpoint precedence: arg > env > default", () => {
43
53
  expect(resolveEndpoint({ endpoint: "http://x/" }, {})).toBe("http://x");
44
54
  expect(resolveEndpoint({}, { FOUNTAIN_ENDPOINT: "http://env" })).toBe("http://env");
45
- expect(resolveEndpoint({}, {})).toBe("https://founta.inevitable.fyi");
55
+ expect(resolveEndpoint({}, {})).toBe("https://fountain.inevitable.fyi");
46
56
  });
47
57
 
48
- it("splitSecrets extracts valid entries and leaves the body clean", () => {
49
- const { body, secrets } = splitSecrets({
58
+ it("parseManifest reads kind/name/spec from each YAML document", () => {
59
+ const resources = parseManifest(MANIFEST);
60
+ expect(resources).toEqual([
61
+ { kind: "Environment", name: "concierge-env", spec: { networking_type: "limited" } },
62
+ {
63
+ kind: "Agent",
64
+ name: "researcher",
65
+ spec: { model: "a/m", runtime: "claude", environment: "concierge-env" },
66
+ },
67
+ ]);
68
+ });
69
+
70
+ it("parseManifest skips documents with an unknown or missing kind", () => {
71
+ const resources = parseManifest(
72
+ "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: n\nspec: {}\n",
73
+ );
74
+ expect(resources).toEqual([]);
75
+ });
76
+
77
+ it("parseManifest ignores blank documents", () => {
78
+ expect(parseManifest("")).toEqual([]);
79
+ expect(parseManifest("\n---\n\n")).toEqual([]);
80
+ });
81
+
82
+ it("toApplyPayload converts the authored secrets array into a key/value map", () => {
83
+ const payload = toApplyPayload({
50
84
  name: "e",
51
85
  secrets: [{ key: "K", value: "v" }, { bogus: true }],
52
86
  });
53
- expect(body).toEqual({ name: "e" });
54
- expect(secrets).toEqual([{ key: "K", value: "v" }]);
87
+ expect(payload).toEqual({ name: "e", secrets: { K: "v" } });
88
+ });
89
+
90
+ it("toApplyPayload passes spec through unchanged when there is no secrets array", () => {
91
+ const spec = { name: "e", networking_type: "limited" };
92
+ expect(toApplyPayload(spec)).toEqual(spec);
55
93
  });
56
94
 
57
95
  it("isChantOwned keys on the metadata marker", () => {
@@ -62,67 +100,170 @@ describe("pure helpers", () => {
62
100
  });
63
101
 
64
102
  describe("fountainApply", () => {
65
- it("creates in Environment -> Vault -> Agent order and resolves the environment ref", async () => {
66
- const plan: FountainPlan = {
67
- researcher: {
68
- kind: "Agent",
69
- spec: { name: "researcher", model: "a/m", runtime: "claude", environment: "conciergeEnv" },
70
- },
71
- conciergeEnv: { kind: "Environment", spec: { name: "concierge-env", networking_type: "limited" } },
72
- };
73
-
103
+ it("sends the whole manifest in one POST /api/apply call", async () => {
74
104
  const { http, calls } = fakeHttp({
75
- ...EMPTY_LISTS,
76
- "POST /api/environments": { status: 201, json: { data: { id: "env-1" } } },
77
- "POST /api/agents": { status: 201, json: { data: { id: "agent-1" } } },
105
+ "POST /api/apply": {
106
+ status: 200,
107
+ json: {
108
+ data: {
109
+ results: [
110
+ { kind: "Environment", name: "concierge-env", action: "created", errors: null, secrets: [] },
111
+ { kind: "Agent", name: "researcher", action: "created", errors: null, secrets: [] },
112
+ ],
113
+ },
114
+ },
115
+ },
78
116
  });
79
117
 
80
- const summary = await fountainApply({ planContent: JSON.stringify(plan) }, http);
118
+ const summary = await fountainApply({ manifestContent: MANIFEST }, http);
81
119
 
82
- const mutations = calls.filter((c) => c.method === "POST");
83
- expect(mutations.map((c) => c.path)).toEqual(["/api/environments", "/api/agents"]);
84
- const agentBody = mutations[1].body as Record<string, unknown>;
85
- expect(agentBody.environment_id).toBe("env-1");
86
- expect(agentBody.environment).toBeUndefined();
120
+ expect(calls).toHaveLength(1);
121
+ expect(calls[0]).toMatchObject({ method: "POST", path: "/api/apply" });
122
+ const body = calls[0].body as { resources: Array<{ kind: string; name: string; spec: unknown }> };
123
+ expect(body.resources).toEqual([
124
+ { kind: "Environment", name: "concierge-env", spec: { networking_type: "limited" } },
125
+ { kind: "Agent", name: "researcher", spec: { model: "a/m", runtime: "claude", environment: "concierge-env" } },
126
+ ]);
87
127
  expect(summary.created).toEqual(["Environment/concierge-env", "Agent/researcher"]);
88
128
  });
89
129
 
90
- it("updates by name when the resource already exists", async () => {
91
- const plan: FountainPlan = {
92
- v: { kind: "Vault", spec: { name: "staging-creds", description: "d" } },
93
- };
130
+ it("does not resolve the agent's environment reference client-side — the server does that now", async () => {
94
131
  const { http, calls } = fakeHttp({
95
- ...EMPTY_LISTS,
96
- "GET /api/vaults": { status: 200, json: { data: [{ id: "v-9", name: "staging-creds" }] } },
97
- "PUT /api/vaults/v-9": { status: 200, json: { data: { id: "v-9" } } },
132
+ "POST /api/apply": {
133
+ status: 200,
134
+ json: { data: { results: [{ kind: "Agent", name: "researcher", action: "created", errors: null, secrets: [] }] } },
135
+ },
98
136
  });
99
137
 
100
- const summary = await fountainApply({ planContent: JSON.stringify(plan) }, http);
101
- expect(summary.updated).toEqual(["Vault/staging-creds"]);
102
- expect(calls.some((c) => c.method === "PUT" && c.path === "/api/vaults/v-9")).toBe(true);
138
+ await fountainApply({ manifestContent: MANIFEST }, http);
139
+ const body = calls[0].body as { resources: Array<{ spec: Record<string, unknown> }> };
140
+ expect(body.resources[1].spec.environment).toBe("concierge-env");
141
+ expect(body.resources[1].spec.environment_id).toBeUndefined();
103
142
  });
104
143
 
105
- it("upserts secrets through the sub-resource", async () => {
106
- const plan: FountainPlan = {
107
- e: {
108
- kind: "Environment",
109
- spec: { name: "e", secrets: [{ key: "OPENAI_API_KEY", value: "${FROM_PROVIDER}" }] },
110
- },
111
- };
144
+ it("reports updated actions and converts spec.secrets to a map on the wire", async () => {
145
+ const manifest = `apiVersion: fountain.dev/v1
146
+ kind: Environment
147
+ metadata:
148
+ name: e
149
+ spec:
150
+ secrets:
151
+ - key: OPENAI_API_KEY
152
+ value: sk-abc
153
+ `;
112
154
  const { http, calls } = fakeHttp({
113
- ...EMPTY_LISTS,
114
- "POST /api/environments": { status: 201, json: { data: { id: "env-1" } } },
115
- "POST /api/environments/env-1/secrets": { status: 201 },
155
+ "POST /api/apply": {
156
+ status: 200,
157
+ json: {
158
+ data: {
159
+ results: [
160
+ {
161
+ kind: "Environment",
162
+ name: "e",
163
+ action: "updated",
164
+ errors: null,
165
+ secrets: [{ key: "OPENAI_API_KEY", action: "upserted", errors: null }],
166
+ },
167
+ ],
168
+ },
169
+ },
170
+ },
116
171
  });
117
172
 
118
- const summary = await fountainApply({ planContent: JSON.stringify(plan) }, http);
173
+ const summary = await fountainApply({ manifestContent: manifest }, http);
174
+ expect(summary.updated).toEqual(["Environment/e"]);
119
175
  expect(summary.secretsUpserted).toBe(1);
120
- const create = calls.find((c) => c.method === "POST" && c.path === "/api/environments")!;
121
- expect((create.body as Record<string, unknown>).secrets).toBeUndefined();
176
+ const body = calls[0].body as { resources: Array<{ spec: Record<string, unknown> }> };
177
+ expect(body.resources[0].spec.secrets).toEqual({ OPENAI_API_KEY: "sk-abc" });
178
+ });
179
+
180
+ it("throws with every failure once all results are in, not just the first", async () => {
181
+ const manifest = `apiVersion: fountain.dev/v1
182
+ kind: Environment
183
+ metadata:
184
+ name: bad-env
185
+ spec: {}
186
+ ---
187
+ apiVersion: fountain.dev/v1
188
+ kind: Vault
189
+ metadata:
190
+ name: bad-vault
191
+ spec: {}
192
+ `;
193
+ const { http } = fakeHttp({
194
+ "POST /api/apply": {
195
+ status: 200,
196
+ json: {
197
+ data: {
198
+ results: [
199
+ {
200
+ kind: "Environment",
201
+ name: "bad-env",
202
+ action: "error",
203
+ errors: { name: ["has already been taken"] },
204
+ secrets: [],
205
+ },
206
+ {
207
+ kind: "Vault",
208
+ name: "bad-vault",
209
+ action: "error",
210
+ errors: { name: ["has already been taken"] },
211
+ secrets: [],
212
+ },
213
+ ],
214
+ },
215
+ },
216
+ },
217
+ });
218
+
219
+ await expect(fountainApply({ manifestContent: manifest }, http)).rejects.toThrow(/2 failure/);
220
+ });
221
+
222
+ it("throws on a failed secret upsert", async () => {
223
+ const manifest = `apiVersion: fountain.dev/v1
224
+ kind: Vault
225
+ metadata:
226
+ name: v
227
+ spec:
228
+ secrets:
229
+ - key: BAD
230
+ value: x
231
+ `;
232
+ const { http } = fakeHttp({
233
+ "POST /api/apply": {
234
+ status: 200,
235
+ json: {
236
+ data: {
237
+ results: [
238
+ {
239
+ kind: "Vault",
240
+ name: "v",
241
+ action: "created",
242
+ errors: null,
243
+ secrets: [{ key: "BAD", action: "error", errors: { value: ["must be a string"] } }],
244
+ },
245
+ ],
246
+ },
247
+ },
248
+ },
249
+ });
250
+
251
+ await expect(fountainApply({ manifestContent: manifest }, http)).rejects.toThrow(/BAD/);
252
+ });
253
+
254
+ it("throws when the server rejects the request outright", async () => {
255
+ const { http } = fakeHttp({ "POST /api/apply": { status: 500 } });
256
+ await expect(fountainApply({ manifestContent: MANIFEST }, http)).rejects.toThrow(/500/);
257
+ });
258
+
259
+ it("skips the POST entirely for an empty manifest", async () => {
260
+ const { http, calls } = fakeHttp({});
261
+ const summary = await fountainApply({ manifestContent: "" }, http);
262
+ expect(calls).toHaveLength(0);
263
+ expect(summary).toEqual({ created: [], updated: [], pruned: [], secretsUpserted: 0 });
122
264
  });
123
265
 
124
266
  it("prunes only chant-owned resources, in reverse kind order", async () => {
125
- const plan: FountainPlan = {};
126
267
  const { http, calls } = fakeHttp({
127
268
  "GET /api/environments": {
128
269
  status: 200,
@@ -142,15 +283,35 @@ describe("fountainApply", () => {
142
283
  "DELETE /api/environments/e-1": { status: 204 },
143
284
  });
144
285
 
145
- const summary = await fountainApply({ planContent: "{}", prune: true }, http);
286
+ const summary = await fountainApply({ manifestContent: "", prune: true }, http);
146
287
  const deletes = calls.filter((c) => c.method === "DELETE").map((c) => c.path);
147
288
  expect(deletes).toEqual(["/api/agents/a-1", "/api/environments/e-1"]);
148
289
  expect(summary.pruned).toEqual(["Agent/owned-agent", "Environment/owned-env"]);
149
290
  });
150
291
 
151
- it("parsePlan round-trips", () => {
152
- const plan = parsePlan('{"e":{"kind":"Environment","spec":{"name":"e"}}}');
153
- expect(plan.e.kind).toBe("Environment");
292
+ it("does not prune a resource still present in the manifest", async () => {
293
+ const manifest = `apiVersion: fountain.dev/v1
294
+ kind: Environment
295
+ metadata:
296
+ name: kept-env
297
+ spec: {}
298
+ `;
299
+ const { http, calls } = fakeHttp({
300
+ "POST /api/apply": {
301
+ status: 200,
302
+ json: { data: { results: [{ kind: "Environment", name: "kept-env", action: "updated", errors: null, secrets: [] }] } },
303
+ },
304
+ "GET /api/environments": {
305
+ status: 200,
306
+ json: { data: [{ id: "e-1", name: "kept-env", metadata: { "managed-by": "chant" } }] },
307
+ },
308
+ "GET /api/vaults": { status: 200, json: { data: [] } },
309
+ "GET /api/agents": { status: 200, json: { data: [] } },
310
+ });
311
+
312
+ const summary = await fountainApply({ manifestContent: manifest, prune: true }, http);
313
+ expect(calls.some((c) => c.method === "DELETE")).toBe(false);
314
+ expect(summary.pruned).toEqual([]);
154
315
  });
155
316
  });
156
317
 
@@ -1,30 +1,35 @@
1
1
  /**
2
2
  * fountain native applier.
3
3
  *
4
- * Direct-REST reconciler against fountain's API (the same surface
5
- * `fountain apply` uses, but API-first: create-if-new, update-if-changed
6
- * by name, optional owned-only prune). Input is the serializer's
7
- * `fountain-plan.json` sidecar entity name → { kind, spec } — so no
8
- * YAML parsing happens on the apply side.
4
+ * Compiles the serializer's manifest YAML — the same `apiVersion:
5
+ * fountain.dev/v1` documents `fountain apply -f` accepts into fountain's
6
+ * `POST /api/apply` bulk-apply request and sends it in one call. That
7
+ * endpoint (BinaryBourbon/fountain#151) does the reconciliation server-side:
8
+ * upsert by name, environments -> vaults -> agents, an agent's
9
+ * `spec.environment` name resolved against the manifest or the tenant's
10
+ * existing environments, secrets upserted through the encrypted envelope
11
+ * path. Best-effort per resource — every result is collected before this
12
+ * throws, so one bad resource doesn't hide failures in the rest of the
13
+ * manifest, and a partial apply is never silently reported as clean.
9
14
  *
10
- * Apply order is Environment Vault Agent: agents reference their
11
- * environment by entity name in the plan, resolved to the live id here
12
- * (mirroring the CLI's name→id resolution in apply.go).
15
+ * No id resolution happens here anymore: since the server resolves an
16
+ * agent's `environment` reference by name itself, the manifest's `spec`
17
+ * passes through unmodified except for one shape adjustment — chant's
18
+ * authored `secrets` is an ordered `{key, value}[]`, the wire format wants
19
+ * `{KEY: value}` (see `toApplyPayload`).
13
20
  *
14
- * Secrets: `spec.secrets` entries ({key, value}) are stripped from the
15
- * resource body and upserted through the secrets sub-resource. Values are
16
- * write-only upstream, so this is upsert-always — a changed value cannot
17
- * be detected, only overwritten. (BinaryBourbon/fountain#148's reference
18
- * model would make this a plain diff; until then, upsert-always is the
19
- * honest semantic.)
21
+ * Prune (opt-in, chant-owned only) isn't part of bulk apply, so it still
22
+ * lists each kind's live state and deletes what's absent from the
23
+ * manifest, same as before.
20
24
  *
21
25
  * Endpoint/token resolution: explicit args win, then FOUNTAIN_ENDPOINT /
22
26
  * FOUNTAIN_TOKEN, then the hosted default endpoint.
23
27
  */
24
28
 
25
29
  import { readFileSync } from "node:fs";
30
+ import { parseYAML } from "@intentius/chant/yaml";
26
31
 
27
- export const DEFAULT_FOUNTAIN_BASE_URL = "https://founta.inevitable.fyi";
32
+ export const DEFAULT_FOUNTAIN_BASE_URL = "https://fountain.inevitable.fyi";
28
33
 
29
34
  /** Ownership marker checked by the owned-only prune. */
30
35
  export const OWNERSHIP_KEY = "managed-by";
@@ -36,28 +41,29 @@ const KIND_PATHS: Record<string, string> = {
36
41
  Agent: "agents",
37
42
  };
38
43
 
39
- /** Environments before vaults before agents; prune runs in reverse. */
44
+ const KINDS = new Set(Object.keys(KIND_PATHS));
45
+
46
+ /** Prune-only ordering now — bulk apply reconciles create/update order itself. */
40
47
  const APPLY_ORDER = ["Environment", "Vault", "Agent"] as const;
41
48
 
42
- export interface PlanEntry {
49
+ export interface ManifestResource {
43
50
  kind: string;
51
+ name: string;
44
52
  spec: Record<string, unknown>;
45
53
  }
46
54
 
47
- export type FountainPlan = Record<string, PlanEntry>;
48
-
49
55
  export interface FountainHttp {
50
56
  (method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }>;
51
57
  }
52
58
 
53
59
  export interface FountainApplyArgs {
54
- /** Path to the serializer's fountain-plan.json. */
55
- planPath?: string;
56
- /** Inline plan content (takes precedence over planPath). */
57
- planContent?: string;
60
+ /** Path to the serializer's compiled fountain manifest YAML. */
61
+ manifestPath?: string;
62
+ /** Inline manifest YAML content (takes precedence over manifestPath). */
63
+ manifestContent?: string;
58
64
  endpoint?: string;
59
65
  token?: string;
60
- /** Delete chant-owned resources absent from the plan. Off by default. */
66
+ /** Delete chant-owned resources absent from the manifest. Off by default. */
61
67
  prune?: boolean;
62
68
  }
63
69
 
@@ -87,22 +93,41 @@ export function resolveToken(
87
93
  return token;
88
94
  }
89
95
 
90
- export function parsePlan(content: string): FountainPlan {
91
- return JSON.parse(content) as FountainPlan;
96
+ function isRecord(v: unknown): v is Record<string, unknown> {
97
+ return typeof v === "object" && v !== null && !Array.isArray(v);
98
+ }
99
+
100
+ /** Parse the serializer's multi-document manifest YAML into apply resources. Pure. */
101
+ export function parseManifest(content: string): ManifestResource[] {
102
+ const resources: ManifestResource[] = [];
103
+ for (const docText of content.split(/^---\s*$/m)) {
104
+ if (!docText.trim()) continue;
105
+ const doc = parseYAML(docText);
106
+ const kind = typeof doc.kind === "string" ? doc.kind : "";
107
+ if (!KINDS.has(kind)) continue;
108
+ const meta = isRecord(doc.metadata) ? doc.metadata : {};
109
+ const name = typeof meta.name === "string" ? meta.name : "";
110
+ const spec = isRecord(doc.spec) ? doc.spec : {};
111
+ resources.push({ kind, name, spec });
112
+ }
113
+ return resources;
92
114
  }
93
115
 
94
- /** Split spec.secrets ({key, value}[]) from the resource body. Pure. */
95
- export function splitSecrets(spec: Record<string, unknown>): {
96
- body: Record<string, unknown>;
97
- secrets: Array<{ key: string; value: string }>;
98
- } {
99
- const { secrets, ...body } = spec;
100
- if (!Array.isArray(secrets)) return { body: spec, secrets: [] };
101
- const valid = secrets.filter(
102
- (s): s is { key: string; value: string } =>
103
- !!s && typeof s === "object" && typeof (s as { key?: unknown }).key === "string",
104
- );
105
- return { body, secrets: valid };
116
+ /**
117
+ * Fountain's bulk-apply spec takes `secrets` as a `{KEY: value}` map;
118
+ * chant's authored shape is an ordered `{key, value}[]`. Convert only that
119
+ * one field the rest of spec passes through untouched. Pure.
120
+ */
121
+ export function toApplyPayload(spec: Record<string, unknown>): Record<string, unknown> {
122
+ const { secrets, ...rest } = spec;
123
+ if (!Array.isArray(secrets)) return spec;
124
+ const map: Record<string, string> = {};
125
+ for (const entry of secrets) {
126
+ if (entry && typeof entry === "object" && typeof (entry as { key?: unknown }).key === "string") {
127
+ map[(entry as { key: string }).key] = String((entry as { value?: unknown }).value ?? "");
128
+ }
129
+ }
130
+ return { ...rest, secrets: map };
106
131
  }
107
132
 
108
133
  /** Is a live resource chant-owned (by its metadata marker)? Pure. */
@@ -142,6 +167,20 @@ interface LiveResource {
142
167
  metadata?: Record<string, unknown>;
143
168
  }
144
169
 
170
+ interface ApplyResultSecret {
171
+ key: string;
172
+ action: string;
173
+ errors?: Record<string, unknown> | null;
174
+ }
175
+
176
+ interface ApplyResult {
177
+ kind: string;
178
+ name: string;
179
+ action: string;
180
+ errors?: Record<string, unknown> | null;
181
+ secrets?: ApplyResultSecret[];
182
+ }
183
+
145
184
  async function listByName(http: FountainHttp, kind: string): Promise<Map<string, LiveResource>> {
146
185
  const { status, json } = await http("GET", `/api/${KIND_PATHS[kind]}`);
147
186
  if (status !== 200) throw new Error(`fountainApply: list ${kind} failed (${status})`);
@@ -153,83 +192,46 @@ export async function fountainApply(
153
192
  args: FountainApplyArgs,
154
193
  http?: FountainHttp,
155
194
  ): Promise<FountainApplySummary> {
156
- const content = args.planContent ?? readFileSync(args.planPath!, "utf-8");
157
- const plan = parsePlan(content);
195
+ const content = args.manifestContent ?? readFileSync(args.manifestPath!, "utf-8");
196
+ const resources = parseManifest(content);
158
197
 
159
198
  const endpoint = resolveEndpoint(args);
160
199
  const client = http ?? defaultFountainHttp(endpoint, resolveToken(args));
161
200
 
162
201
  const summary: FountainApplySummary = { created: [], updated: [], pruned: [], secretsUpserted: 0 };
163
- const idByKindAndName = new Map<string, string>();
164
- const live = new Map<string, Map<string, LiveResource>>();
165
- for (const kind of APPLY_ORDER) {
166
- live.set(kind, await listByName(client, kind));
167
- }
168
-
169
- for (const kind of APPLY_ORDER) {
170
- for (const [entityName, entry] of Object.entries(plan)) {
171
- if (entry.kind !== kind) continue;
172
-
173
- const { body, secrets } = splitSecrets(entry.spec);
174
- const payload: Record<string, unknown> = { ...body };
175
- // Manifest identity: the fountain resource is named after the spec's
176
- // own `name` when present, else the entity name.
177
- const resourceName = typeof payload.name === "string" ? (payload.name as string) : entityName;
178
- payload.name = resourceName;
179
-
180
- // Agent environment ref: the plan carries the referenced *entity*
181
- // name; resolve through the id map (created this run) or live state.
182
- if (kind === "Agent" && typeof payload.environment === "string") {
183
- const refEntity = payload.environment as string;
184
- const refPlanEntry = plan[refEntity];
185
- const refResourceName =
186
- refPlanEntry && typeof refPlanEntry.spec.name === "string"
187
- ? (refPlanEntry.spec.name as string)
188
- : refEntity;
189
- const envId =
190
- idByKindAndName.get(`Environment/${refEntity}`) ??
191
- live.get("Environment")?.get(refResourceName)?.id;
192
- if (!envId) throw new Error(`fountainApply: agent "${entityName}" references unknown environment "${refEntity}"`);
193
- delete payload.environment;
194
- payload.environment_id = envId;
195
- }
196
202
 
197
- const existing = live.get(kind)!.get(resourceName);
198
- let id: string;
199
- if (existing) {
200
- const { status } = await client("PUT", `/api/${KIND_PATHS[kind]}/${existing.id}`, payload);
201
- if (status !== 200) throw new Error(`fountainApply: update ${kind} "${resourceName}" failed (${status})`);
202
- id = existing.id;
203
- summary.updated.push(`${kind}/${resourceName}`);
204
- } else {
205
- const { status, json } = await client("POST", `/api/${KIND_PATHS[kind]}`, payload);
206
- if (status !== 201 && status !== 200) {
207
- throw new Error(`fountainApply: create ${kind} "${resourceName}" failed (${status})`);
208
- }
209
- id = (json as { data?: { id?: string } })?.data?.id ?? "";
210
- summary.created.push(`${kind}/${resourceName}`);
211
- }
212
- idByKindAndName.set(`${kind}/${entityName}`, id);
213
-
214
- // Secrets sub-resource: upsert-always (values are write-only upstream).
215
- if (secrets.length > 0 && (kind === "Environment" || kind === "Vault")) {
216
- for (const secret of secrets) {
217
- const { status } = await client("POST", `/api/${KIND_PATHS[kind]}/${id}/secrets`, secret);
218
- if (status !== 200 && status !== 201) {
219
- throw new Error(`fountainApply: secret upsert on ${kind} "${resourceName}" failed (${status})`);
220
- }
221
- summary.secretsUpserted += 1;
222
- }
203
+ if (resources.length > 0) {
204
+ const body = {
205
+ resources: resources.map((r) => ({ kind: r.kind, name: r.name, spec: toApplyPayload(r.spec) })),
206
+ };
207
+ const { status, json } = await client("POST", "/api/apply", body);
208
+ if (status !== 200) {
209
+ throw new Error(`fountainApply: POST /api/apply failed (${status})`);
210
+ }
211
+ const results = (json as { data?: { results?: ApplyResult[] } })?.data?.results ?? [];
212
+
213
+ const failures: string[] = [];
214
+ for (const r of results) {
215
+ const label = `${r.kind}/${r.name}`;
216
+ if (r.action === "created") summary.created.push(label);
217
+ else if (r.action === "updated") summary.updated.push(label);
218
+ else failures.push(`${label}: ${JSON.stringify(r.errors)}`);
219
+
220
+ for (const s of r.secrets ?? []) {
221
+ if (s.action === "upserted") summary.secretsUpserted += 1;
222
+ else failures.push(`${label} secret "${s.key}": ${JSON.stringify(s.errors)}`);
223
223
  }
224
224
  }
225
+ if (failures.length > 0) {
226
+ throw new Error(`fountainApply: ${failures.length} failure(s):\n ${failures.join("\n ")}`);
227
+ }
225
228
  }
226
229
 
227
230
  if (args.prune) {
228
- const planned = new Set<string>();
229
- for (const [entityName, entry] of Object.entries(plan)) {
230
- const resourceName =
231
- typeof entry.spec.name === "string" ? (entry.spec.name as string) : entityName;
232
- planned.add(`${entry.kind}/${resourceName}`);
231
+ const planned = new Set(resources.map((r) => `${r.kind}/${r.name}`));
232
+ const live = new Map<string, Map<string, LiveResource>>();
233
+ for (const kind of APPLY_ORDER) {
234
+ live.set(kind, await listByName(client, kind));
233
235
  }
234
236
  // Reverse order: agents first, then vaults, then environments.
235
237
  for (const kind of [...APPLY_ORDER].reverse()) {
@@ -8,8 +8,8 @@ export {
8
8
  fountainApply,
9
9
  resolveEndpoint,
10
10
  resolveToken,
11
- parsePlan,
12
- splitSecrets,
11
+ parseManifest,
12
+ toApplyPayload,
13
13
  isChantOwned,
14
14
  defaultFountainHttp,
15
15
  DEFAULT_FOUNTAIN_BASE_URL,
@@ -19,8 +19,7 @@ export {
19
19
  export type {
20
20
  FountainApplyArgs,
21
21
  FountainApplySummary,
22
- FountainPlan,
23
- PlanEntry,
22
+ ManifestResource,
24
23
  FountainHttp,
25
24
  } from "./fountain-apply";
26
25
 
package/src/plugin.ts CHANGED
@@ -81,7 +81,7 @@ export const fountainPlugin: LexiconPlugin = {
81
81
  "Declare, lint, and reconcile fountain Environments, Vaults, and Agents from a chant project",
82
82
  triggers: [
83
83
  { type: "context" as const, value: "fountain" },
84
- { type: "context" as const, value: "founta.inevitable.fyi" },
84
+ { type: "context" as const, value: "fountain.inevitable.fyi" },
85
85
  ],
86
86
  },
87
87
  {
@@ -109,7 +109,7 @@ export const fountainPlugin: LexiconPlugin = {
109
109
  return [
110
110
  createDiffTool(
111
111
  fountainSerializer,
112
- "Compare current fountain build output (fountain manifests + fountain-plan.json) against previous version",
112
+ "Compare current fountain build output (fountain manifest YAML) against previous version",
113
113
  "fountain",
114
114
  ),
115
115
  ];
@@ -2,10 +2,6 @@ import { describe, expect, it } from "vitest";
2
2
  import { fountainSerializer } from "./serializer";
3
3
  import type { Declarable } from "@intentius/chant";
4
4
 
5
- function primary(result: unknown): string {
6
- return typeof result === "string" ? result : (result as { primary: string }).primary;
7
- }
8
-
9
5
  function entity(entityType: string, props: Record<string, unknown>): Declarable {
10
6
  return { entityType, lexicon: "fountain", ...props } as unknown as Declarable;
11
7
  }
@@ -20,13 +16,11 @@ describe("fountain serializer", () => {
20
16
  expect(fountainSerializer.serialize(new Map())).toBe("");
21
17
  });
22
18
 
23
- it("emits the fountain-plan.json sidecar for the applier", () => {
19
+ it("emits only the manifest YAML no sidecar file", () => {
24
20
  const env = entity("Fountain::V1::Environment", { name: "e" });
25
21
  const result = fountainSerializer.serialize(new Map([["e", env]]));
26
- expect(typeof result).toBe("object");
27
- const files = (result as { files: Record<string, string> }).files;
28
- const plan = JSON.parse(files["fountain-plan.json"]);
29
- expect(plan.e.kind).toBe("Environment");
22
+ expect(typeof result).toBe("string");
23
+ expect(result).toContain("kind: Environment");
30
24
  });
31
25
 
32
26
  it("emits a fountain manifest per entity", () => {
@@ -36,7 +30,7 @@ describe("fountain serializer", () => {
36
30
  networking_config: { allowed_hosts: ["github.com"] },
37
31
  });
38
32
 
39
- const out = primary(fountainSerializer.serialize(new Map([["conciergeEnv", env]])));
33
+ const out = fountainSerializer.serialize(new Map([["conciergeEnv", env]]));
40
34
 
41
35
  expect(out).toContain("apiVersion: fountain.dev/v1");
42
36
  expect(out).toContain("kind: Environment");
@@ -50,13 +44,11 @@ describe("fountain serializer", () => {
50
44
  const env = entity("Fountain::V1::Environment", { name: "e" });
51
45
  const vault = entity("Fountain::V1::Vault", { name: "v" });
52
46
 
53
- const out = primary(
54
- fountainSerializer.serialize(
55
- new Map([
56
- ["env", env],
57
- ["vault", vault],
58
- ]),
59
- ),
47
+ const out = fountainSerializer.serialize(
48
+ new Map([
49
+ ["env", env],
50
+ ["vault", vault],
51
+ ]),
60
52
  );
61
53
 
62
54
  expect(out).toContain("---\n");
@@ -73,13 +65,11 @@ describe("fountain serializer", () => {
73
65
  environment: env,
74
66
  });
75
67
 
76
- const out = primary(
77
- fountainSerializer.serialize(
78
- new Map([
79
- ["conciergeEnv", env],
80
- ["researcher", agent],
81
- ]),
82
- ),
68
+ const out = fountainSerializer.serialize(
69
+ new Map([
70
+ ["conciergeEnv", env],
71
+ ["researcher", agent],
72
+ ]),
83
73
  );
84
74
 
85
75
  expect(out).toContain("environment: conciergeEnv");
@@ -91,7 +81,7 @@ describe("fountain serializer", () => {
91
81
  description: "true",
92
82
  });
93
83
 
94
- const out = primary(fountainSerializer.serialize(new Map([["v", vault]])));
84
+ const out = fountainSerializer.serialize(new Map([["v", vault]]));
95
85
  expect(out).toContain('description: "true"');
96
86
  });
97
87
 
@@ -103,7 +93,7 @@ describe("fountain serializer", () => {
103
93
  mcp_servers: { github: { env: { GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}" } } },
104
94
  });
105
95
 
106
- const out = primary(fountainSerializer.serialize(new Map([["a", agent]])));
96
+ const out = fountainSerializer.serialize(new Map([["a", agent]]));
107
97
  expect(out).toContain('GITHUB_PERSONAL_ACCESS_TOKEN: "${GITHUB_PAT}"');
108
98
  });
109
99
  });
package/src/serializer.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Serializer, SerializerResult, Declarable } from "@intentius/chant";
1
+ import type { Serializer, Declarable } from "@intentius/chant";
2
2
  import { walkValue, type SerializerVisitor } from "@intentius/chant/serializer-walker";
3
3
  import { propsOf } from "./entity-props";
4
4
 
@@ -8,8 +8,13 @@ import { propsOf } from "./entity-props";
8
8
  * single multi-document stream. The output stays `fountain apply -f`
9
9
  * compatible, so anyone can eject from chant and keep the artifacts.
10
10
  *
11
+ * This is also `fountainApply`'s own input now: since fountain's bulk
12
+ * `POST /api/apply` (BinaryBourbon/fountain#151) resolves name references
13
+ * server-side, there's no separate sidecar to keep in sync — the manifest
14
+ * YAML is the only artifact.
15
+ *
11
16
  * Cross-resource references (e.g. `agent.environment`) serialize to the
12
- * referenced entity's name — fountain's CLI resolves names to ids at apply.
17
+ * referenced entity's name — fountain resolves names to ids at apply.
13
18
  */
14
19
 
15
20
  const API_VERSION = "fountain.dev/v1";
@@ -24,7 +29,7 @@ export const fountainSerializer: Serializer = {
24
29
  name: "fountain",
25
30
  rulePrefix: "FTN",
26
31
 
27
- serialize(entities: Map<string, Declarable>): string | SerializerResult {
32
+ serialize(entities: Map<string, Declarable>): string {
28
33
  // Reverse map for reference resolution: Declarable instance → name.
29
34
  const entityNames = new Map<Declarable, string>();
30
35
  for (const [name, entity] of entities) {
@@ -50,7 +55,6 @@ export const fountainSerializer: Serializer = {
50
55
  };
51
56
 
52
57
  const docs: string[] = [];
53
- const plan: Record<string, { kind: string; spec: Record<string, unknown> }> = {};
54
58
  for (const [name, entity] of entities) {
55
59
  const spec: Record<string, unknown> = {};
56
60
  for (const [key, val] of Object.entries(propsOf(entity))) {
@@ -65,16 +69,9 @@ export const fountainSerializer: Serializer = {
65
69
  spec,
66
70
  };
67
71
  docs.push(toYaml(manifest));
68
- plan[name] = { kind: manifest.kind, spec };
69
72
  }
70
73
 
71
- const yaml = docs.join("---\n");
72
- if (docs.length === 0) return yaml;
73
-
74
- // Primary output is the ejectable `fountain apply -f` YAML; the JSON
75
- // sidecar is the fountainApply op's input (same data, no YAML parser
76
- // needed on the apply side — the fly plan.json pattern).
77
- return { primary: yaml, files: { "fountain-plan.json": JSON.stringify(plan, null, 2) } };
74
+ return docs.join("---\n");
78
75
  },
79
76
  };
80
77
 
@@ -11,7 +11,7 @@ user-invocable: true
11
11
  Everything materialized into a fountain sandbox must be presumed exfiltrated once untrusted agent code runs. Order of preference:
12
12
 
13
13
  1. **`${VAR}` substitution references** in agent config (MCP server env, system prompts). Resolved at spawn from the merged environment + vault sets. Never a value in source.
14
- 2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` upserts them through the sub-resource; a changed value cannot be detected, only overwritten.
14
+ 2. **Environment secrets** (`spec.secrets`) — encrypted at rest, write-only over the API (values are never returned once stored). `fountainApply` sends them inline with the rest of the resource in the bulk apply request, and the server upserts them through the encrypted envelope path; a changed value cannot be detected, only overwritten.
15
15
  3. **`env_vars`** — plaintext config only. FTN012 errors on credential-shaped keys or values here.
16
16
 
17
17
  Never put a literal credential anywhere in a declaration: FTN001 catches known shapes (AWS keys, GitHub/Slack tokens, `sk-`/`ftn_` keys, private key material) at the AST; FTN015 errors on secret-shaped MCP env keys that are not `${VAR}` references.
@@ -10,7 +10,7 @@ user-invocable: true
10
10
 
11
11
  [fountain](https://github.com/BinaryBourbon/fountain) runs coding agents in sandboxed VMs. Its workload layer is three kinds — `Environment` (sandbox baseline), `Vault` (env-var overrides), `Agent` (a runnable agent config) — and this lexicon declares them as typed chant resources. Conversations are runs, not resources: start them with the `fountainRun` op, never declare them.
12
12
 
13
- The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim) plus a `fountain-plan.json` sidecar that `fountainApply` reconciles against the API directly: create-if-new, update-by-name, opt-in owned-only prune keyed on the `managed-by: chant` metadata marker.
13
+ The source of truth is the TypeScript in `src/`. `chant build` serializes it to fountain's own manifest YAML (ejectable — `fountain apply -f` accepts it verbatim). `fountainApply` sends that same YAML to fountain's bulk `POST /api/apply` endpoint in one request: create-if-new, update-by-name, opt-in owned-only prune keyed on the `managed-by: chant` metadata marker.
14
14
 
15
15
  ## Authoring
16
16
 
package/src/spec/fetch.ts CHANGED
@@ -23,7 +23,7 @@ import { readFileSync } from "fs";
23
23
  import { fileURLToPath } from "url";
24
24
  import { fetchWithCache } from "@intentius/chant/codegen/fetch";
25
25
 
26
- const SCHEMA_URL = "https://founta.inevitable.fyi/api/openapi.json";
26
+ const SCHEMA_URL = "https://fountain.inevitable.fyi/api/openapi.json";
27
27
 
28
28
  const CACHE_FILE = join(homedir(), ".chant", "fountain-openapi.json");
29
29
 
File without changes