@intentius/chant-lexicon-fountain 0.76.0 → 0.78.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.
Files changed (56) hide show
  1. package/README.md +6 -5
  2. package/dist/coverage.d.ts +6 -5
  3. package/dist/coverage.d.ts.map +1 -1
  4. package/dist/generated/index.d.ts.map +1 -1
  5. package/dist/integrity.json +10 -9
  6. package/dist/lint/audit-catalog.d.ts.map +1 -1
  7. package/dist/lint/post-synth/ftn023-acp-runtime-command.d.ts +3 -2
  8. package/dist/lint/post-synth/ftn023-acp-runtime-command.d.ts.map +1 -1
  9. package/dist/lint/post-synth/ftn024-setup-timeout-range.d.ts +3 -0
  10. package/dist/lint/post-synth/ftn024-setup-timeout-range.d.ts.map +1 -0
  11. package/dist/lint/post-synth/index.d.ts.map +1 -1
  12. package/dist/lsp/hover.d.ts.map +1 -1
  13. package/dist/manifest.json +2 -2
  14. package/dist/meta.json +14 -2
  15. package/dist/okf/index.md +2 -1
  16. package/dist/okf/rules/FTN023.md +2 -2
  17. package/dist/okf/rules/FTN024.md +15 -0
  18. package/dist/okf/types/Agent.md +7 -5
  19. package/dist/okf/types/Environment.md +3 -0
  20. package/dist/okf/types/Vault.md +1 -0
  21. package/dist/rules/ftn016-runtime-model-valid.ts +5 -5
  22. package/dist/rules/ftn023-acp-runtime-command.ts +4 -3
  23. package/dist/rules/ftn024-setup-timeout-range.ts +48 -0
  24. package/dist/skills/chant-fountain-ops.md +6 -5
  25. package/dist/skills/chant-fountain-secrets.md +2 -2
  26. package/dist/skills/chant-fountain.md +2 -2
  27. package/dist/spec/fetch.d.ts +18 -3
  28. package/dist/spec/fetch.d.ts.map +1 -1
  29. package/dist/spec/parse.d.ts +5 -3
  30. package/dist/spec/parse.d.ts.map +1 -1
  31. package/dist/types/index.d.ts +14 -6
  32. package/package.json +2 -2
  33. package/src/codegen/docs.ts +2 -2
  34. package/src/composites/composites.test.ts +19 -0
  35. package/src/coverage.test.ts +8 -0
  36. package/src/coverage.ts +24 -8
  37. package/src/generated/index.d.ts +14 -6
  38. package/src/generated/index.ts +2 -2
  39. package/src/generated/lexicon-fountain.json +14 -2
  40. package/src/lint/audit-catalog.ts +11 -3
  41. package/src/lint/post-synth/ftn016-runtime-model-valid.ts +5 -5
  42. package/src/lint/post-synth/ftn023-acp-runtime-command.ts +4 -3
  43. package/src/lint/post-synth/ftn024-setup-timeout-range.ts +48 -0
  44. package/src/lint/post-synth/index.ts +2 -0
  45. package/src/lint/post-synth/post-synth.test.ts +24 -0
  46. package/src/lsp/hover.test.ts +9 -2
  47. package/src/lsp/hover.ts +6 -4
  48. package/src/plugin.test.ts +1 -1
  49. package/src/serializer.test.ts +2 -2
  50. package/src/skills/chant-fountain-ops.md +6 -5
  51. package/src/skills/chant-fountain-secrets.md +2 -2
  52. package/src/skills/chant-fountain.md +2 -2
  53. package/src/spec/fetch.ts +46 -6
  54. package/src/spec/fountain-openapi.snapshot.json +12221 -4739
  55. package/src/spec/parse.ts +84 -41
  56. package/src/spec/spec.test.ts +163 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intentius/chant-lexicon-fountain",
3
- "version": "0.76.0",
3
+ "version": "0.78.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.76.0",
53
+ "@intentius/chant": "^0.78.0",
54
54
  "typescript": "^5.9.3",
55
55
  "zod": "^4.3.6"
56
56
  },
@@ -13,7 +13,7 @@ import { docsPipeline, writeDocsSite, type DocsConfig } from "@intentius/chant/c
13
13
 
14
14
  const pkgDir = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
15
15
 
16
- const overview = `The **fountain** lexicon declares [fountain](https://github.com/BinaryBourbon/fountain)'s workload layer as typed chant resources. fountain runs coding agents in sandboxed VMs. Six kinds are declarable: \`Environment\` (sandbox baseline), \`Vault\` (env-var overrides) and \`Agent\` (a runnable agent config) are what \`fountain apply\` reconciles; \`Teammate\` (an agent seated on the team, with a thread of its own), \`Schedule\` (a cron prompt into that thread) and \`Webhook\` (where the estate's events leave it) belong to the team, schedule and webhook routes.
16
+ const overview = `The **fountain** lexicon declares [fountain](https://github.com/managoat/fountain)'s workload layer as typed chant resources. fountain runs coding agents in sandboxed VMs. Six kinds are declarable: \`Environment\` (sandbox baseline), \`Vault\` (env-var overrides) and \`Agent\` (a runnable agent config) are what \`fountain apply\` reconciles; \`Teammate\` (an agent seated on the team, with a thread of its own), \`Schedule\` (a cron prompt into that thread) and \`Webhook\` (where the estate's events leave it) belong to the team, schedule and webhook routes.
17
17
 
18
18
  Types are generated from a pinned fountain release's OpenAPI spec, so they track the real API.
19
19
 
@@ -91,7 +91,7 @@ The output is ejectable — \`fountain apply -f\` accepts it verbatim, so adopti
91
91
 
92
92
  \`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 then Vault then 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).
93
93
 
94
- Bulk apply accepts those three kinds only. A \`Teammate\`, \`Schedule\` or \`Webhook\` document is emitted and is valid, and \`fountainApply\` does not send it yet: applying the three through their own routes waits on chant #2127, and on BinaryBourbon/fountain#1636 for a bulk call that covers them.
94
+ \`fountainApply\` sends those three kinds in the bulk call. A \`Teammate\`, \`Schedule\` or \`Webhook\` document goes through its own route afterwards, matched by name (a webhook by url). fountain v0.21.0's bulk apply accepts all six kinds (fountain#1636), and \`fountainApply\` has not moved the team-side three onto it yet.
95
95
 
96
96
  ## Ownership
97
97
 
@@ -5,6 +5,9 @@ import { Steward, stewardForOp, __resetStewardsForTests } from "./steward";
5
5
  import { Environment, Vault } from "../generated/index";
6
6
  import { fountainSerializer } from "../serializer";
7
7
  import type { Declarable } from "@intentius/chant";
8
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
9
+ import { runtimeModelValidCheck } from "../lint/post-synth/ftn016-runtime-model-valid";
10
+ import { acpRuntimeCommandCheck } from "../lint/post-synth/ftn023-acp-runtime-command";
8
11
 
9
12
  describe("ConciergeStack", () => {
10
13
  it("defaults to deny-all egress, no vaults, and the ownership marker", () => {
@@ -61,6 +64,22 @@ describe("Steward", () => {
61
64
  const toolchain = () => new Environment({ name: "toolchain" });
62
65
  const creds = () => new Vault({ name: "prod-creds" });
63
66
 
67
+ it("passes FTN016 and FTN023 now that acp is upstream's runtime, not chant's extension", () => {
68
+ const { agent, teammate } = Steward({
69
+ name: "prod-steward",
70
+ environment: toolchain(),
71
+ ops: [op("prod-watch", { cron: "*/10 * * * *" })],
72
+ });
73
+ const entities = new Map<string, Declarable>([
74
+ ["agent", agent as unknown as Declarable],
75
+ ["teammate", teammate as unknown as Declarable],
76
+ ]);
77
+ const ctx = { outputs: new Map(), entities, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
78
+
79
+ expect(runtimeModelValidCheck.check(ctx)).toEqual([]);
80
+ expect(acpRuntimeCommandCheck.check(ctx)).toEqual([]);
81
+ });
82
+
64
83
  it("returns an acp agent, a teammate, a schedule per scheduled op, and the webhook", () => {
65
84
  const environment = toolchain();
66
85
  const vault = creds();
@@ -44,6 +44,14 @@ describe("fountain coverage", () => {
44
44
  expect(report.unaccountedKinds).toEqual([]);
45
45
  });
46
46
 
47
+ it("excludes only schemas the pinned spec still has", () => {
48
+ // An exclusion for a schema upstream removed matches nothing and proves
49
+ // nothing. v0.21.0 dropped three that v0.16.0 had (#2505).
50
+ const schemas = (JSON.parse(spec) as { components: { schemas: Record<string, unknown> } }).components.schemas;
51
+ const stale = Object.keys(EXCLUDED_KINDS).filter((name) => !(name in schemas));
52
+ expect(stale).toEqual([]);
53
+ });
54
+
47
55
  it("flags a property upstream added that the surface lacks", () => {
48
56
  const stripped = structuredClone(surface);
49
57
  stripped.entries.Agent.props = stripped.entries.Agent.props!.filter(
package/src/coverage.ts CHANGED
@@ -25,11 +25,12 @@ import { parseFountainOpenAPI, fountainShortName, MODELED_REQUEST_SCHEMAS } from
25
25
  /**
26
26
  * Request schemas with no typed resource, and why.
27
27
  *
28
- * Every `*Request` schema in the pinned spec is either modeled or listed here.
29
- * v0.16.0 describes the whole product, not just the workload layer, so most of
30
- * this list is one restatement of the same three reasons: it is a session and
31
- * not a thing, it is an account operation and not estate, or it is write-only
32
- * and so could never be diffed.
28
+ * Every `*Request` schema in the pinned spec is either modeled or listed here,
29
+ * and every name listed here is a schema the pinned spec has (the coverage test
30
+ * holds both). The spec describes the whole product, not just the workload
31
+ * layer, so most of this list restates three reasons: it is a session and not a
32
+ * thing, it is an account operation and not estate, or it is write-only and so
33
+ * could never be diffed.
33
34
  */
34
35
  export const EXCLUDED_KINDS: Record<string, string> = {
35
36
  // Runs, turns, and the envelope around them.
@@ -37,7 +38,15 @@ export const EXCLUDED_KINDS: Record<string, string> = {
37
38
  PromptRequest: "turn-level input inside a conversation run",
38
39
  PermissionAnswerRequest: "a human's answer to one tool card mid-run — an event on a conversation, not estate",
39
40
  TeamMessageRequest: "one turn addressed to a teammate — the run, not the seat",
40
- ChatCompletionRequest: "the OpenAI-compatible inference shim; a request to a model, unrelated to estate",
41
+ ConversationLabelsRequest:
42
+ "merges labels into one conversation; a runtime action on a run, not estate",
43
+ ConversationReapplyRequest:
44
+ "swaps the Agent, Environment or Vault under a running conversation; a runtime action on a run, not estate",
45
+ SandboxRequest:
46
+ "a queued start or schedule run waiting for sandbox capacity; runtime state, and a response record despite the name",
47
+ PendingPermissionRequest:
48
+ "a permission request still open after its turn ended; runtime state on a conversation, and a response record " +
49
+ "despite the name. Answering it is PermissionAnswerRequest",
41
50
  ApplyRequest:
42
51
  "the envelope fountainApply builds around a manifest, not a thing anyone declares — " +
43
52
  "its contents are the Environment/Vault/Agent resources, which are modeled",
@@ -50,14 +59,19 @@ export const EXCLUDED_KINDS: Record<string, string> = {
50
59
  "every apply, or reporting it permanently unobservable. Same reason as SecretRequest",
51
60
  InferenceCredentialRequest: "a provider key, write-only for the same reason as ApiKeyRequest",
52
61
  SecretBindingRequest: "binds a stored secret to a host for the egress broker; the value behind it is write-only",
62
+ VaultSecretMetadataRequest:
63
+ "edits a stored secret's advisory expiry. The secret stays write-only; its expiry is metadata chant#2391 " +
64
+ "proposes surfacing as drift rather than declaring",
65
+ InferenceCredentialSetCreateRequest:
66
+ "an account-level store of provider API keys, write-only. A kind of its own if it is ever modeled; until then " +
67
+ "an Agent names one by uuid in inference_credential_id",
68
+ InferenceCredentialSetUpdateRequest: "renames an inference credential set or makes it the default; same reason as the create",
53
69
 
54
70
  // Partial updates of a modeled kind — chant declares the whole thing.
55
71
  TeamRenameRequest: "a partial update of a modeled Teammate — chant declares the full shape and applies it",
56
72
  TeamScheduleUpdateRequest: "a partial update of a modeled Schedule",
57
73
  WebhookEndpointUpdateRequest: "a partial update of a modeled Webhook",
58
- TeamContactRequest: "sets a teammate's email and phone (flag team_comms) — a per-seat contact detail, not estate",
59
74
  AvatarRequest: "sets an agent's avatar image; presentation, not configuration",
60
- AvatarGenerateRequest: "asks fountain to draw an avatar — a one-shot action with no resource behind it",
61
75
 
62
76
  // The account, its people, and its money.
63
77
  RegisterRequest: "account signup",
@@ -73,6 +87,8 @@ export const EXCLUDED_KINDS: Record<string, string> = {
73
87
  CreditsCheckoutRequest: "starts a Stripe checkout for credits",
74
88
  SupportReportCreateRequest: "files a support report",
75
89
  ConnectionProviderRequest: "registers an OAuth app for third-party connections; the connections themselves are per-user grants",
90
+ OAuthClientRequest: "registers an OAuth client app against fountain; app registration, not estate",
91
+ OAuthClientUpdateRequest: "renames an OAuth client or changes its redirect URIs; app registration, not estate",
76
92
  BuzzProvisionRequest: "provisions a Buzz identity for an agent — a separate product surface",
77
93
  BuzzAccessUpdateRequest: "changes who may use a Buzz identity",
78
94
 
@@ -4,20 +4,24 @@
4
4
 
5
5
  export declare class Agent {
6
6
  constructor(props: {
7
- model: string;
8
7
  name: string;
9
8
  runtime: "acp" | "claude" | "codex" | "gemini" | "opencode";
10
- /** Environments a conversation may launch this agent under instead of its own. Same shape as allowed_vault_ids: null (default) allows any environment the tenant owns; an empty list forbids overriding; a non-empty list is an allowlist. The agent's own environment always passes. */
9
+ /** Environments a conversation may launch this agent under instead of its own (environment_id on create). Same shape as allowed_vault_ids: null (default) allows any environment the tenant owns; an empty list forbids overriding; a non-empty list is an allowlist. The agent's own environment always passes. */
11
10
  allowed_environment_ids?: string[];
11
+ /** Credential sets a conversation may launch this agent on instead of the agent's (inference_credential_id on create). Same shape as allowed_vault_ids: null (default) allows any set the tenant owns; an empty list forbids overriding; a non-empty list is an allowlist. The agent's own set always passes. */
12
+ allowed_inference_credential_ids?: string[];
12
13
  /** Vaults a conversation may attach to this agent. null (default) allows any vault the tenant owns; an empty list forbids attaching any vault; a non-empty list is an allowlist. */
13
14
  allowed_vault_ids?: string[];
14
15
  description?: string;
15
16
  environment_id?: string;
17
+ /** The credential set this agent's conversations run on. null (default) is the account's default set, which is what every agent had before an account could hold more than one. */
18
+ inference_credential_id?: string;
16
19
  mcp_servers?: Record<string, unknown>;
17
20
  metadata?: Record<string, unknown>;
21
+ model?: string;
18
22
  /** Per-tool permission policy: a map of key to verdict, plus an optional "default" key. A key is matched against the tool card's title first and then ACP's kind (execute, edit, read, fetch, …); prefer a kind, because claude titles a tool call with the command it is about to run. Unset keys fall back to the default, and an unset default is auto_allow. "ask" holds the tool until a human answers it on the conversation stream, and denies if nobody does before the timeout. A conversation may narrow this at launch, never widen it. A runtime that never asks (opencode) refuses anything stricter than auto_allow with 422 permission_policy_unenforceable. */
19
- permission_policy?: Record<string, "ask" | "auto_allow" | "auto_deny">;
20
- /** The command line an "acp" agent speaks the Agent Client Protocol over, e.g. "chant acp". chant extension, pending BinaryBourbon/fountain#1634 — an instance without that PR rejects it at apply. */
23
+ permission_policy?: Record<string, "ask" | "auto_allow" | "auto_deny" | number>;
24
+ /** The command the acp runtime launches inside the sandbox, as a shell line resolved there (for example `chant acp`). Required when runtime is acp, and rejected on every other runtime, which resolves its own executable. A free string by design: it runs under the same isolation as an environment's setup script. */
21
25
  runtime_command?: string;
22
26
  /** Where a conversation of this agent runs by default (ADR 0023). ephemeral: a sandbox per conversation, reclaimed with it. persistent: one sandbox per agent identity (agent, environment, vault) — the agent's computer — that every conversation of that identity lands on and shares; it survives a conversation ending and is parked, not destroyed, at the ceiling. A launch may name the other with sandbox_mode on POST /api/conversations. */
23
27
  sandbox_mode?: "ephemeral" | "persistent";
@@ -45,10 +49,13 @@ export declare class Environment {
45
49
  networking_type?: "limited" | "unrestricted";
46
50
  packages?: Record<string, unknown>;
47
51
  repositories?: Repository[];
52
+ /** Secrets upserted with the resource at apply, as key/value pairs; fountainApply sends them as the manifest's inline `secrets` map. Values are write-only upstream and can never be read back or diffed. Write a reference that resolves at build (an env var, a secret-manager lookup), never a literal: FTN001 flags a literal here as it does anywhere else in a declaration. */
53
+ secrets?: { key: string; value: string }[];
48
54
  setup_script?: string;
55
+ /** Setup exec timeout in seconds; defaults to 120. The overall provisioning deadline still applies. */
56
+ setup_timeout_seconds?: number;
49
57
  });
50
58
  readonly agent_count: number;
51
- readonly allowed_environment_ids: string[];
52
59
  readonly id: string;
53
60
  readonly inserted_at: string;
54
61
  readonly secret_count: number;
@@ -87,7 +94,6 @@ export declare class Teammate {
87
94
  /** Layer this vault's secrets on top. Must satisfy allowed_vault_ids. */
88
95
  vault?: Vault | string;
89
96
  });
90
- readonly contact: Record<string, unknown>;
91
97
  readonly conversation: Record<string, unknown>;
92
98
  readonly last_turn: Record<string, unknown>;
93
99
  readonly presence: Record<string, unknown>;
@@ -101,6 +107,8 @@ export declare class Vault {
101
107
  name: string;
102
108
  description?: string;
103
109
  metadata?: Record<string, unknown>;
110
+ /** Secrets upserted with the resource at apply, as key/value pairs; fountainApply sends them as the manifest's inline `secrets` map. Values are write-only upstream and can never be read back or diffed. Write a reference that resolves at build (an env var, a secret-manager lookup), never a literal: FTN001 flags a literal here as it does anywhere else in a declaration. */
111
+ secrets?: { key: string; value: string }[];
104
112
  });
105
113
  readonly id: string;
106
114
  readonly inserted_at: string;
@@ -2,9 +2,9 @@
2
2
  import { createResource, createProperty } from "./runtime";
3
3
 
4
4
  export const Agent = createResource("Fountain::V1::Agent", "fountain", {"acp":"acp","avatar_media_type":"avatar_media_type","conversation_count":"conversation_count","id":"id","inserted_at":"inserted_at","updated_at":"updated_at"});
5
- export const Environment = createResource("Fountain::V1::Environment", "fountain", {"agent_count":"agent_count","allowed_environment_ids":"allowed_environment_ids","id":"id","inserted_at":"inserted_at","secret_count":"secret_count","updated_at":"updated_at"});
5
+ export const Environment = createResource("Fountain::V1::Environment", "fountain", {"agent_count":"agent_count","id":"id","inserted_at":"inserted_at","secret_count":"secret_count","updated_at":"updated_at"});
6
6
  export const Schedule = createResource("Fountain::V1::Schedule", "fountain", {"agent_id":"agent_id","id":"id","inserted_at":"inserted_at","last_conversation_id":"last_conversation_id","last_error":"last_error","last_run_at":"last_run_at","next_run_at":"next_run_at","updated_at":"updated_at"});
7
- export const Teammate = createResource("Fountain::V1::Teammate", "fountain", {"contact":"contact","conversation":"conversation","last_turn":"last_turn","presence":"presence","preview":"preview","unread":"unread","usage_total":"usage_total"});
7
+ export const Teammate = createResource("Fountain::V1::Teammate", "fountain", {"conversation":"conversation","last_turn":"last_turn","presence":"presence","preview":"preview","unread":"unread","usage_total":"usage_total"});
8
8
  export const Vault = createResource("Fountain::V1::Vault", "fountain", {"id":"id","inserted_at":"inserted_at","secret_count":"secret_count","updated_at":"updated_at"});
9
9
  export const Webhook = createResource("Fountain::V1::Webhook", "fountain", {"consecutive_failures":"consecutive_failures","disabled_at":"disabled_at","disabled_reason":"disabled_reason","id":"id","inserted_at":"inserted_at","status":"status","updated_at":"updated_at"});
10
10
 
@@ -5,9 +5,11 @@
5
5
  "lexicon": "fountain",
6
6
  "props": [
7
7
  "allowed_environment_ids",
8
+ "allowed_inference_credential_ids",
8
9
  "allowed_vault_ids",
9
10
  "description",
10
11
  "environment_id",
12
+ "inference_credential_id",
11
13
  "mcp_servers",
12
14
  "metadata",
13
15
  "model",
@@ -24,6 +26,9 @@
24
26
  "environment_id": {
25
27
  "format": "uuid"
26
28
  },
29
+ "inference_credential_id": {
30
+ "format": "uuid"
31
+ },
27
32
  "model": {
28
33
  "pattern": "^[a-z0-9_-]+/[a-z0-9._-]+$"
29
34
  },
@@ -68,7 +73,9 @@
68
73
  "networking_type",
69
74
  "packages",
70
75
  "repositories",
71
- "setup_script"
76
+ "secrets",
77
+ "setup_script",
78
+ "setup_timeout_seconds"
72
79
  ],
73
80
  "propertyConstraints": {
74
81
  "name": {
@@ -80,6 +87,10 @@
80
87
  "unrestricted",
81
88
  "limited"
82
89
  ]
90
+ },
91
+ "setup_timeout_seconds": {
92
+ "minimum": 1,
93
+ "maximum": 900
83
94
  }
84
95
  }
85
96
  },
@@ -154,7 +165,8 @@
154
165
  "props": [
155
166
  "description",
156
167
  "metadata",
157
- "name"
168
+ "name",
169
+ "secrets"
158
170
  ],
159
171
  "propertyConstraints": {
160
172
  "name": {
@@ -20,8 +20,8 @@ import { fountainAuditLineage } from "./audit-lineage";
20
20
  import { applyLineage } from "@intentius/chant/audit/catalog";
21
21
 
22
22
  const FOUNTAIN_PRIMITIVES: Authority = {
23
- name: "fountain — Environment, Vault, Agent, Teammate, Schedule and Webhook primitives",
24
- url: "https://github.com/BinaryBourbon/fountain/blob/main/docs/primitives.md",
23
+ name: "fountain: Environment, Vault, Agent, Teammate, Schedule and Webhook primitives",
24
+ url: "https://github.com/managoat/fountain/blob/main/docs/primitives.md",
25
25
  };
26
26
 
27
27
  const OWASP_LLM_INJECTION: Authority = {
@@ -114,7 +114,7 @@ export const fountainAuditCatalog: Record<string, RuleMeta> = {
114
114
  "merge-worthy",
115
115
  "correctness",
116
116
  "Two declarations of one kind resolve to the same fountain name",
117
- "fountain reconciles by name — rename one, or the second silently overwrites the first.",
117
+ "fountain reconciles by name, so rename one. Otherwise the second silently overwrites the first.",
118
118
  ),
119
119
  FTN020: rule(
120
120
  "FTN020",
@@ -148,6 +148,14 @@ export const fountainAuditCatalog: Record<string, RuleMeta> = {
148
148
  'Agent runtime "acp" is missing runtime_command, or another runtime carries one',
149
149
  "Pair the acp runtime with the command it speaks the protocol over, and drop the field elsewhere.",
150
150
  ),
151
+ FTN024: rule(
152
+ "FTN024",
153
+ "merge-worthy",
154
+ "correctness",
155
+ "Environment setup_timeout_seconds is outside 1 to 900, or not a whole number",
156
+ "Use an integer from 1 to 900, or omit the field for fountain's default of 120. Changing the value " +
157
+ "invalidates the environment's checkpoints.",
158
+ ),
151
159
  };
152
160
 
153
161
  // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
@@ -8,11 +8,11 @@ import { propsOf } from "../../entity-props";
8
8
  * this backstops untyped construction (imported templates, hand-built
9
9
  * plans) so a typo fails the build instead of a 422 at apply.
10
10
  *
11
- * `acp` is the exception on both counts. It is a chant extension pending
12
- * BinaryBourbon/fountain#1634, and it names a process rather than a hosted
13
- * model: the model is whatever the command on the other end of the protocol
14
- * decides to use, so a `model` on an acp agent is a value nothing reads. The
15
- * command itself is FTN023's business.
11
+ * `acp` is the exception on the model. The runtime is in the spec since
12
+ * fountain v0.21.0, which also stopped requiring `model`, and it names a
13
+ * process rather than a hosted model: the model is whatever the command on the
14
+ * other end of the protocol decides to use, so a `model` on an acp agent is a
15
+ * value nothing reads. The command itself is FTN023's business.
16
16
  */
17
17
 
18
18
  const RUNTIMES = new Set(["claude", "codex", "gemini", "opencode", "acp"]);
@@ -11,12 +11,13 @@ import { propsOf } from "../../entity-props";
11
11
  * expects to run that nothing will ever execute, which is worse than an error
12
12
  * because it looks configured.
13
13
  *
14
- * Both fields are chant extensions pending BinaryBourbon/fountain#1634; the
15
- * rule holds the shape steady until upstream enforces it.
14
+ * Both fields are in the spec since fountain v0.21.0, and the server refuses
15
+ * either half without the other at apply. The rule moves that refusal to
16
+ * build, where it is a diagnostic in review instead of a 422 in a run.
16
17
  */
17
18
  export const acpRuntimeCommandCheck: PostSynthCheck = {
18
19
  id: "FTN023",
19
- description: 'Agent runtime "acp" requires runtime_command, and no other runtime accepts one',
20
+ description: "Agent runtime \"acp\" requires runtime_command, and no other runtime accepts one",
20
21
 
21
22
  check(ctx: PostSynthContext): PostSynthDiagnostic[] {
22
23
  const diagnostics: PostSynthDiagnostic[] = [];
@@ -0,0 +1,48 @@
1
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
2
+ import { propsOf } from "../../entity-props";
3
+
4
+ /**
5
+ * FTN024: an Environment's `setup_timeout_seconds` is a whole number from 1 to 900.
6
+ *
7
+ * fountain v0.21.0 declares the field as an integer with `minimum: 1` and
8
+ * `maximum: 900`, and defaults it to 120 when it is absent. Codegen types it as
9
+ * `number` and carries neither bound into validation, so without this rule a
10
+ * 901 or a 12.5 builds cleanly and the first anyone hears of it is a 422 at
11
+ * apply.
12
+ *
13
+ * A value in range is still worth a second look in review. Changing it
14
+ * invalidates the environment's checkpoints (recorded on chant#2391), so an
15
+ * edit to this field costs what an edit to `setup_script` costs.
16
+ */
17
+
18
+ const MIN = 1;
19
+ const MAX = 900;
20
+
21
+ export const setupTimeoutRangeCheck: PostSynthCheck = {
22
+ id: "FTN024",
23
+ description: "Environment setup_timeout_seconds must be an integer from 1 to 900",
24
+
25
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
26
+ const diagnostics: PostSynthDiagnostic[] = [];
27
+
28
+ for (const [name, entity] of ctx.entities) {
29
+ if (entity.entityType !== "Fountain::V1::Environment") continue;
30
+ const timeout = propsOf(entity).setup_timeout_seconds;
31
+ if (timeout === undefined || timeout === null) continue;
32
+
33
+ if (typeof timeout !== "number" || !Number.isInteger(timeout) || timeout < MIN || timeout > MAX) {
34
+ diagnostics.push({
35
+ checkId: "FTN024",
36
+ severity: "error",
37
+ message:
38
+ `Environment "${name}" setup_timeout_seconds ${JSON.stringify(timeout)} is not an integer ` +
39
+ `from ${MIN} to ${MAX}; fountain refuses it at apply`,
40
+ entity: name,
41
+ lexicon: "fountain",
42
+ });
43
+ }
44
+ }
45
+
46
+ return diagnostics;
47
+ },
48
+ };
@@ -12,6 +12,7 @@ import { scheduleCronSyntaxCheck } from "./ftn020-schedule-cron-syntax";
12
12
  import { typedReferencesResolveCheck } from "./ftn021-typed-references-resolve";
13
13
  import { webhookUrlPublicHttpsCheck } from "./ftn022-webhook-url-public-https";
14
14
  import { acpRuntimeCommandCheck } from "./ftn023-acp-runtime-command";
15
+ import { setupTimeoutRangeCheck } from "./ftn024-setup-timeout-range";
15
16
 
16
17
  export const postSynthChecks: PostSynthCheck[] = [
17
18
  networkingExplicitCheck,
@@ -26,4 +27,5 @@ export const postSynthChecks: PostSynthCheck[] = [
26
27
  typedReferencesResolveCheck,
27
28
  webhookUrlPublicHttpsCheck,
28
29
  acpRuntimeCommandCheck,
30
+ setupTimeoutRangeCheck,
29
31
  ];
@@ -13,6 +13,7 @@ import { scheduleCronSyntaxCheck } from "./ftn020-schedule-cron-syntax";
13
13
  import { typedReferencesResolveCheck } from "./ftn021-typed-references-resolve";
14
14
  import { webhookUrlPublicHttpsCheck } from "./ftn022-webhook-url-public-https";
15
15
  import { acpRuntimeCommandCheck } from "./ftn023-acp-runtime-command";
16
+ import { setupTimeoutRangeCheck } from "./ftn024-setup-timeout-range";
16
17
 
17
18
  function ctx(entities: Record<string, Record<string, unknown>>): PostSynthContext {
18
19
  const map = new Map<string, Declarable>();
@@ -280,3 +281,26 @@ describe("FTN023 acp-runtime-command", () => {
280
281
  ).toHaveLength(0);
281
282
  });
282
283
  });
284
+
285
+ describe("FTN024 setup-timeout-range", () => {
286
+ const env = (setup_timeout_seconds: unknown) =>
287
+ ctx({ e: { entityType: ENV, name: "e", networking_type: "limited", setup_timeout_seconds } });
288
+
289
+ it.each([0, 901, 12.5, -1, "120"])("errors on %j", (value) => {
290
+ const diags = setupTimeoutRangeCheck.check(env(value));
291
+ expect(diags).toHaveLength(1);
292
+ expect(diags[0].checkId).toBe("FTN024");
293
+ expect(diags[0].message).toContain("1 to 900");
294
+ });
295
+
296
+ it.each([1, 120, 900])("is silent on %j", (value) => {
297
+ expect(setupTimeoutRangeCheck.check(env(value))).toHaveLength(0);
298
+ });
299
+
300
+ it("is silent when the field is absent, and on other kinds", () => {
301
+ expect(setupTimeoutRangeCheck.check(ctx({ e: { entityType: ENV, name: "e" } }))).toHaveLength(0);
302
+ expect(
303
+ setupTimeoutRangeCheck.check(ctx({ v: { entityType: VAULT, name: "v", setup_timeout_seconds: 901 } })),
304
+ ).toHaveLength(0);
305
+ });
306
+ });
@@ -46,8 +46,15 @@ describe("LSP hover", () => {
46
46
  expect(hover(ctx("Webhook"))?.contents).toContain("RFC1918");
47
47
  });
48
48
 
49
- it("says the acp runtime is a chant extension pending upstream", () => {
50
- expect(hover(ctx("Agent"))?.contents).toContain("BinaryBourbon/fountain#1634");
49
+ it("describes the acp runtime without calling it an extension", () => {
50
+ const agent = hover(ctx("Agent"))?.contents;
51
+ expect(agent).toContain("runtime_command");
52
+ expect(agent).toContain("FTN023");
53
+ expect(agent).not.toContain("#1634");
54
+ });
55
+
56
+ it("points an Environment's setup timeout at FTN024", () => {
57
+ expect(hover(ctx("Environment"))?.contents).toContain("FTN024");
51
58
  });
52
59
 
53
60
  it("marks property types as non-declarable", () => {
package/src/lsp/hover.ts CHANGED
@@ -12,7 +12,9 @@ const KIND_NOTES: Record<string, string> = {
12
12
  Environment:
13
13
  "Sandbox baseline — packages, repositories, env_vars, secrets, networking.\n\n" +
14
14
  "`networking_type: limited` restricts egress to `networking_config.allowed_hosts`; " +
15
- "an empty list denies all egress. `unrestricted` is open — FTN010/FTN011 flag both silence and openness.",
15
+ "an empty list denies all egress. `unrestricted` is open — FTN010/FTN011 flag both silence and openness.\n\n" +
16
+ "`setup_timeout_seconds` is an integer from 1 to 900 (default 120); FTN024 checks the range, " +
17
+ "and changing it invalidates the environment's checkpoints.",
16
18
  Vault:
17
19
  "Env-var overrides attached at conversation create. Vault values win on key " +
18
20
  "collision with the environment, silently — FTN014 surfaces the shadowing in review.",
@@ -20,9 +22,9 @@ const KIND_NOTES: Record<string, string> = {
20
22
  "A runnable agent config bound to one Environment. `allowed_vault_ids`: " +
21
23
  "`null` allows any tenant vault, `[]` forbids all, a list is an allowlist — " +
22
24
  "set `[]` when the reviewed environment must not be overridable at spawn.\n\n" +
23
- "`runtime: \"acp\"` with `runtime_command` is a chant extension pending " +
24
- "BinaryBourbon/fountain#1634; FTN023 keeps the pair together and FTN016 " +
25
- "rejects a model on it.",
25
+ "`runtime: \"acp\"` launches `runtime_command` inside the sandbox and speaks " +
26
+ "the Agent Client Protocol to it. FTN023 keeps the pair together and FTN016 " +
27
+ "rejects a model on it, since the command picks its own.",
26
28
  Teammate:
27
29
  "An Agent seated on the team, with a thread of its own. `agent` is a typed " +
28
30
  "reference; `environment` and `vault` override the agent's own for this " +
@@ -41,7 +41,7 @@ describe("fountain plugin", () => {
41
41
 
42
42
  it("exposes every post-synth check and lint rule", () => {
43
43
  expect(fountainPlugin.lintRules?.()).toHaveLength(1);
44
- expect(fountainPlugin.postSynthChecks?.()).toHaveLength(12);
44
+ expect(fountainPlugin.postSynthChecks?.()).toHaveLength(13);
45
45
  });
46
46
 
47
47
  it("carries audit metadata for every rule it ships", () => {
@@ -253,8 +253,8 @@ describe("fountain serializer", () => {
253
253
  const [runnable, seat, cadence] = documents(out);
254
254
  expect(seat.spec.agent).toBe("steward");
255
255
  expect(cadence.spec.teammate).toBe("steward-seat");
256
- // The ACP extension survives the round trip — an instance with
257
- // BinaryBourbon/fountain#1634 gets both halves or neither.
256
+ // The acp pair survives the round trip, so the instance gets both
257
+ // halves or neither.
258
258
  expect(runnable.spec.runtime_command).toBe("chant acp");
259
259
  });
260
260
 
@@ -125,7 +125,8 @@ The second posts `chant run <op>` back onto the steward's thread, with
125
125
  now-resolved fact.
126
126
 
127
127
  `--durable-requests` (answering fountain's own permission card instead) is
128
- refused by name: it needs BinaryBourbon/fountain#1635, which has not shipped.
128
+ refused by name: it needs fountain#1635's request-answer path, which fountain
129
+ v0.21.0 carries and chant does not use yet (chant#2391).
129
130
 
130
131
  ## When the steward is busy
131
132
 
@@ -158,7 +159,7 @@ success nobody saw.
158
159
 
159
160
  ## Two upstream caveats
160
161
 
161
- - `runtime: "acp"` with `runtime_command` is BinaryBourbon/fountain#1634 and is
162
- not in v0.16.0. An instance without it rejects the pair at apply.
163
- - Bulk apply covering the team-side kinds is BinaryBourbon/fountain#1636. Until
164
- then they go through their own routes, which is what `fountainApply` does.
162
+ - `runtime: "acp"` with `runtime_command` needs fountain v0.21.0 or later. An
163
+ older instance rejects the pair at apply.
164
+ - Bulk apply covering the team-side kinds is fountain#1636. Until chant uses it,
165
+ they go through their own routes, which is what `fountainApply` does.
@@ -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` 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.
14
+ 2. **Environment and Vault secrets** (`spec.secrets`), typed on both kinds as `secrets: { key: string; value: string }[]`. Encrypted at rest, write-only over the API (values are never returned once stored). Write the value as a reference your build resolves, not the secret itself. `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.
@@ -26,4 +26,4 @@ FTN013 warns when an agent references `${VAR}` and its declared environment has
26
26
 
27
27
  ## Round-trips and their limits
28
28
 
29
- `chant import --from` exports live resources but never secrets: values are write-only upstream, and secret keys are not on the typed request surface. Re-declare imported environments' secrets through your secret provider. Upstream discussion of a reference-based model that would fix this: BinaryBourbon/fountain#148.
29
+ `chant import --from` exports live resources but never secrets: values are write-only upstream, so there is nothing to read back into `secrets`. Re-declare imported environments' secrets through your secret provider. Upstream discussion of a reference-based model that would fix this: BinaryBourbon/fountain#148.
@@ -8,7 +8,7 @@ user-invocable: true
8
8
 
9
9
  ## What this lexicon covers
10
10
 
11
- [fountain](https://github.com/BinaryBourbon/fountain) runs coding agents in sandboxed VMs. Six kinds are declarable, and this lexicon types all of them. `Environment` (sandbox baseline), `Vault` (env-var overrides) and `Agent` (a runnable agent config) are the workload layer; `Teammate` (an agent seated on the team, with a thread of its own), `Schedule` (a cron prompt into that thread) and `Webhook` (where the estate's events leave it) are the team, schedule and webhook routes. Conversations are runs, not resources: start them with the `fountainRun` op, never declare them.
11
+ [fountain](https://github.com/managoat/fountain) runs coding agents in sandboxed VMs. Six kinds are declarable, and this lexicon types all of them. `Environment` (sandbox baseline), `Vault` (env-var overrides) and `Agent` (a runnable agent config) are the workload layer; `Teammate` (an agent seated on the team, with a thread of its own), `Schedule` (a cron prompt into that thread) and `Webhook` (where the estate's events leave it) are the team, schedule and webhook routes. Conversations are runs, not resources: start them with the `fountainRun` op, never declare them.
12
12
 
13
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. Bulk apply covers Environment, Vault and Agent only — a Teammate, Schedule or Webhook document is emitted and valid, and applying it through its own route waits on chant #2127.
14
14
 
@@ -64,6 +64,6 @@ What a client sees:
64
64
  - A run that stops at an unapproved gate replies with the pending fact and the `chant approve <op> <gate>` line, and the turn ends. The gate is a fact on chant's ledger, not a wait — the next run re-evaluates it.
65
65
  - `session/cancel` aborts the in-flight step, runs the op's `onFailure` phases, and ends the turn `cancelled`.
66
66
 
67
- `--durable-requests` turns the gated reply into a `session/request_permission` with `allow_once`/`reject_once` and ends the turn `waiting`; the client answers on a later prompt carrying `_meta.chant.permission`, which records the resolution and re-runs. It is off by default because a request that outlives a turn needs BinaryBourbon/fountain#1635.
67
+ `--durable-requests` turns the gated reply into a `session/request_permission` with `allow_once`/`reject_once` and ends the turn `waiting`; the client answers on a later prompt carrying `_meta.chant.permission`, which records the resolution and re-runs. It is off by default because a request that outlives a turn needs fountain#1635, which v0.21.0 carries and chant does not answer through yet (chant#2391).
68
68
 
69
69
  The server does not redact. A step's output reaches the thread verbatim and fountain redacts secrets on the way in; chant never reads or prints the environment it was spawned with.