Skip to content

feat(api): bulk apply reconciles Teammate, Schedule and Webhook documents - #1675

Closed
lex00 wants to merge 8 commits into
mainfrom
issue-1636-apply-kinds
Closed

feat(api): bulk apply reconciles Teammate, Schedule and Webhook documents#1675
lex00 wants to merge 8 commits into
mainfrom
issue-1636-apply-kinds

Conversation

@lex00

@lex00 lex00 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

POST /api/apply reconciles a manifest of Environment, Vault and Agent documents, and everything else a declared estate needs (team membership, schedules, webhook endpoints) had to be driven by hand through their own routes afterwards. This PR extends the manifest vocabulary so one apply reconciles the whole estate.

  • Add Teammate, Schedule and Webhook kinds, reconciled after the existing three in that order. A Teammate resolves agent, environment and vault by name against the manifest first, then the tenant; a Schedule is keyed by (teammate, name); a Webhook is keyed by url and returns its signing secret only on the apply that creates it.
  • Add an unchanged verdict for all six kinds (Bulk apply reports updated for a document that changed nothing, so a client cannot tell a no-op from a write #1680), so a second identical apply reports every row unchanged. The environment, vault, agent and webhook contexts now record no audit event on a no-op update, which is what the audit rules require and what schedules already did.
  • Add Team.update_teammate/4 to move a teammate's environment and vault binding. It refuses while a turn is running on the teammate's computer and retires the home the old identity named, mirroring Agents.update_agent/3.
  • Fail a Teammate row whose reference resolves to nothing, two Teammate documents that claim the same agent, and a Schedule whose teammate name is ambiguous, each without stopping the rest of the manifest. Every per-document reconcile is now isolated from exceptions.
  • Extend the OpenAPI schemas, regenerate the SDK contract and TypeScript types, update the Swift model, extend the Go CLI's kind list and legacy fallback, and document the kinds under "Bulk apply" and "Apply manifests".

Prune is left out; apply stays additive. The audit actions keep their existing names (team.*, team.schedule.*, webhook_endpoint.*) rather than the webhook.* the issue wrote.

Closes #1636
Closes #1680

Validation:

  • Full suite on the four-branch stack: 4579 tests, 0 failures (core and ee), 144 (fountain_buzz), 33 (fountain_support).
  • mix compile --warnings-as-errors, mix format --check-formatted, mix credo --strict, dialyzer, sobelow and deps.unlock --unused are clean. The prod release assembles.
  • go test ./... and go vet ./... in cli/; the SDK contract check, the TypeScript and Python verifiers and the conformance lint pass. scripts/docs-style.py is clean.
  • Not run here: vale, okf and destink (not installed on the build machine); swift test does not compile on this machine's toolchain on main either.

@lex00 lex00 added enhancement New feature or request area:api HTTP API, OpenAPI spec, CLI contract labels Sep 6, 2026
@managoat-review-loop

managoat-review-loop Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Review Loop · Human review needed

Revision: 18c091f2efcf686a7fc5c39d04a44dba7166288d · 0 fix round(s) · generation 2 · Run: 0fb3bee4-3442-4fd2-9235-43b30028147d

Run deadline exhausted; work remains incomplete

  • medium · needs_human When the same agent already has a live persistent home for the requested destination environment/vault, applying the teammate binding reports updated and retires its current home, but subsequent messages cannot wake the teammate. The wake path tries to insert another home for the destination identity instead of attaching to the existing one, and the unique home constraint rejects it. Repeating the manifest reports unchanged and does not recover the teammate.
    Finding: 0bfeb7943fa6d1bd1f5b97a17e9c0c153199f2b87f540cafe1f6907b76a144f8
    Evidence: An agent can have homes for different environment/vault pairs (_unsafe_find_home/4). update_teammate changes the conversation binding but retains sandbox_id. After retirement, Team.send_message uses wake_conversation for the still-live conversation. create_fresh_sandbox_and_start/4 inserts a persistent sandbox with the new environment/vault (conversations.ex:3257-3271), without looking up an existing destination home or recovering a home uniqueness conflict. Sandbox.changeset/2 enforces sandboxes_home_identity_index. The added tests never create a destination home and never send a message after rebinding.
    Suggested remediation: Define and implement destination-home handling before accepting a rebind: either attach to the existing home using the normal ownership, readiness and quota checks, or refuse the rebind before mutating anything when that destination is occupied. Reuse preserves the persistent-home contract; refusal is simpler but restricts valid bindings. This needs maintainer choice and coordinated lifecycle regression coverage beyond a narrow automatic fix. Test rebinding with both source and destination homes already present, then sending a message.
    Disposition: Reviewer requests a human decision

  • high · needs_human POST /api/apply now returns action="unchanged" for any row whose record already matched the document, and by design that is the common case: an idempotent re-apply returns "unchanged" for every row (apps/fountain/test/fountain_web/controllers/apply_controller_test.exs asserts List.duplicate("unchanged", 6), and docs/cli.md now says "A second apply of an unchanged manifest reports unchanged for every row"). The only shipped client that branches on this value is the Go CLI, and every already-released fountain binary has no "unchanged" case. Adding a value to a response enum is a distinct contract from adding an optional field: the new secret property is ignored safely by old decoders, but a widened enum is not. The contract and conformance suites cannot catch this — CONTRIBUTING.md says both "compare a schema with another schema", and the regenerated sdk/contract/contract.json plus sdk/typescript/src/generated/openapi.ts agree with the server precisely because they were regenerated from it. Nothing in this PR (CHANGELOG.md, docs/cli.md, docs/api.md, apps/fountain/priv/help/manifest.md) tells an operator that the released CLI must be upgraded before the server ships.
    Finding: 3e2bff5d2e0c897c98a52e3b35f5764c88da84e4a81e9778f5e6fead441a4d8d
    Evidence: The call shape is renderApplyResults in the base CLI, git show 539a300:cli/internal/cmd/apply.go lines 145-163:

    switch r.Action {
    case "created": fmt.Printf("%s + %s\n", label, r.Name)
    case "updated": fmt.Printf("%s ~ %s\n", label, r.Name)
    default:
    anyFailed = true
    warnf("%s ! %s: %s", label, r.Name, formatResultErrors(r.Errors))
    }

and runApply (cli/internal/cmd/apply.go:68-70) does if renderApplyResults(results) { os.Exit(1) }. formatResultErrors returns the literal string "apply failed" when errs is empty, and an unchanged row carries errors: nil (Fountain.Manifest.result/6, apps/fountain/lib/fountain/manifest.ex:562). Server side, verdict/2 (apps/fountain/lib/fountain/manifest.ex:549-555) returns :unchanged whenever the schema fields minus timestamps compare equal, and ApplyJSON renders it verbatim (apps/fountain/lib/fountain_web/controllers/apply_json.ex:12). So an operator on a pre-#1636 binary running the pipeline that docs/cli.md and apps/fountain/priv/help/manifest.md both recommend ("keep fountain.yml in source control, run fountain apply -f fountain.yml from your deploy pipeline") gets, on the second and every later run:

env ! my-project: apply failed
vault ! alice: apply failed
agent ! researcher: apply failed

on stderr and exit status 1, against a manifest that applied cleanly and a server that wrote nothing wrong. The Swift SDK is unaffected (sdk/swift/.../Account.swift declares action as a plain String), and the TypeScript change is types only, which is why the CLI is the whole blast radius.
Suggested remediation: This is a product decision the approved base does not settle, so it needs the maintainer rather than an automatic fix. Three alternatives: (1) ship the widened enum and state the incompatibility explicitly — a CHANGELOG note under a Changed/Breaking heading and a line in docs/cli.md saying a fountain binary older than this release reports every unchanged row as a failure and exits nonzero — and sequence the CLI release ahead of the server deploy; (2) keep the wire value at "updated" unless the caller opts in, e.g. an unchanged: true field on ApplyRequest or an explicit client-capability header, so old binaries keep the behavior they were built against and the new CLI gets the = output; (3) narrow the blast radius by having the server emit "unchanged" only for the three new kinds, which no released CLI can send. Whichever is chosen, say it in the release notes: the two schema-to-schema gates will stay green either way.
Disposition: Reviewer requests a human decision

  • medium · needs_human update_teammate/4 writes the same column, with the same blank-trimming and the same "only if it actually moved" rule, as the existing rename_teammate/4: both set the teammate conversation's title and both record metadata %{"fields" => ["name"]}. They disagree only on the action string. A teammate renamed through PATCH /api/team/:agent_id records team.renamed; the identical rename applied from a Teammate document records team.updated. ADR 0013 calls action "the trail's only groupable column" and notes Fountain.Analytics mirrors it through, so an operator or dashboard filtering on team.renamed silently misses every apply-driven rename — the exact door-dependent coverage gap CLAUDE.md's audit section says the Tracker: audit-trail coverage — every meaningful write path should leave an audit event #540 campaign existed to remove. The new team.updated action is justified for the environment/vault bindings, which rename_teammate/4 does not cover; it is only the overlapping name field that is now recorded two ways, along with two copies of the write-and-detect-a-real-change logic.
    Finding: 6dfc40754c14bd8b432b7825384414c2cc2e6ff468f3c1bf2a69ee608c537a1e
    Evidence: apps/fountain/lib/fountain/team.ex:422-440 (rename_teammate/4: blank_to_nil, Conversations.update_conversation(conv, %{"title" => title}), if updated.title != conv.title, record(user_id, "team.renamed", updated, opts, %{"fields" => ["name"]})) versus apps/fountain/lib/fountain/team.ex:504-596 (update_teammate/4: @bindings maps "name" -> :title, binding_changes/2 applies the same blank_to_nil and the same reject-if-equal, record(user_id, "team.updated", updated, opts, %{"fields" => fields})). Both then call broadcast_changed(user_id). Fountain.Manifest is the only non-test caller of update_teammate/4 (apps/fountain/lib/fountain/manifest.ex:321), and apps/fountain/test/fountain/manifest_test.exs exercises a rename-only apply that lands on team.updated.
    Suggested remediation: A maintainer decision, because either direction moves a recorded action string. Option A: have update_teammate/4 emit team.renamed when changes is exactly %{title: _} and team.updated otherwise, so the existing action keeps its meaning and no route's trail changes — smallest change, but two action strings for one function. Option B: make rename_teammate/4 delegate to update_teammate(user_id, agent_id, %{"name" => name}, opts) so there is one write path — cleaner, but it retires team.renamed from PUT/PATCH /api/team/:agent_id and from team_controller.ex:95's documented behaviour, which is a visible change to the audit trail and to analytics grouping. Option C: accept the split and document it. Whichever is chosen, fold the duplicated title-write into one place and update audit_guardrail_test.exs, team_test.exs and team_controller_test.exs accordingly.
    Disposition: Reviewer requests a human decision

  • low · needs_human The PR adds unclaimed/3 for Teammate documents and explains exactly why: two documents that describe one record make every pass rewrite the other's, so no apply is ever unchanged. A Schedule is keyed by name under its teammate and has the same property, but no equivalent guard. Two Schedule documents with the same metadata.name and the same teammate — a plausible copy-paste in a hand-written manifest — resolve to the same row: the first document creates or updates it, the second immediately updates it back, and the same flip-flop repeats forever. Every CI apply then reports created/updated rather than unchanged and writes two team.schedule.updated audit rows, which is precisely the audit noise this PR set out to remove in its ### Fixed entry. Nothing is corrupted and the row converges to whichever document is last, so the cost is a permanently noisy trail and an apply that never reports a clean state.
    Finding: ae8e3540572f841e4006a972be0879bf703c8f547bf3c0589db77d1719e21c6b
    Evidence: apps/fountain/lib/fountain/manifest.ex:283-296 (unclaimed/3, with the comment "without this the second renames the first's conversation on every pass and the manifest is never idempotent") has no counterpart in apply_schedule/4 at apps/fountain/lib/fountain/manifest.ex:328-341. reconcile_schedule/5 (manifest.ex:343-357) finds the row with Schedules.list_schedules(user_id, agent_id) |> Enum.find(&(&1.name == name)), so both documents select the same schedule. reconcile/3 (manifest.ex:136-144) does pass a claimed map to every callback, but apply_schedule/4 ignores it and reconcile_schedule/5 returns nil as the id, so nothing is ever claimed for this kind. apps/fountain/test/fountain/manifest_test.exs covers the duplicate-Teammate case ("a manifest with a duplicate Teammate is still idempotent for the rest") and has no duplicate-Schedule case.
    Suggested remediation: A maintainer decision on the intended semantics, and the reason it is not a one-liner: a Schedule's real key is the pair (teammate, name), not the document name that reconcile/3 claims by, so two same-named schedules under different teammates are legitimate and must keep working. Option A (matches the Teammate precedent): have apply_schedule/4 take the claimed argument reconcile/3 already hands it, key it on {agent_id, name} rather than the document name, and refuse the second document with %{"name" => ["is already used by another Schedule document for this teammate"]}; this needs reconcile/3's accumulator to carry that pair, which fits inside manifest.ex. Option B: declare last-write-wins for schedules, leave the code as is, and say so in the Teammate/Schedule section of apps/fountain/priv/help/manifest.md so a reader is not led by the Teammate rule to expect a refusal. Either way, add a duplicate-Schedule case beside the existing duplicate-Teammate test.
    Disposition: Reviewer requests a human decision

  • low · needs_human The behaviour change is right and matches CLAUDE.md's "Only record what happened ... a no-op sync records nothing", and the CHANGELOG documents it. The shape is the concern: if changeset.changes == %{} is now written out four times in four contexts, each with a comment pointing at Fountain.Environments.update_environment/3 as the canonical copy, and Fountain.Team.Schedules.update_schedule/3 already carried a fifth. Three of the four are structured as result = Repo.update(...) then an if/else that returns result or audited(result, ...), and the fourth (webhooks) as a bare if inside a case, so the same rule reads differently in each place. The next context to gain an update function has nothing to call and will copy a fifth variant, and there is no single place a reader can check which contexts observe the rule.
    Finding: 2f23c82e473f28ea47a614cb5caf69b0d8d80414a34eaa549d60f53f0ff94b55
    Evidence: apps/fountain/lib/fountain/environments.ex:135-152, apps/fountain/lib/fountain/vaults.ex:109-120, apps/fountain/lib/fountain/agents.ex:186-193 and apps/fountain/lib/fountain/webhooks.ex:123-135 all guard on changeset.changes == %{} (or != %{}); three of them carry the comment "A save that moves nothing records nothing, the same rule Fountain.Environments.update_environment/3 follows (Bulk apply: accept Teammate, Schedule and Webhook documents so one manifest reconciles the whole estate #1636)". apps/fountain/lib/fountain/team/schedules.ex:122 has the pre-existing if changeset.changes != %{} form.
    Suggested remediation: Add one helper next to the audit plumbing — e.g. Fountain.Audit.changed?/1 or an audited_if_changed(result, changeset, action, opts) shape each context's private audited/3 can wrap — and have the five call sites use it, so the rule and its comment live in one place. This spans five files and so exceeds the one-file fix budget; it is also worth a maintainer's call whether the helper belongs in Fountain.Audit or stays as a per-context idiom, given each context's audited/3 is private and resource-typed.
    Disposition: Reviewer requests a human decision

  • low · needs_human Showing a webhook signing secret once at creation is the established design, and fountain webhooks create prints it the same way, so this is consistent rather than novel. What is new is the surface: fountain apply is the one command the docs explicitly recommend wiring into an unattended pipeline ("Useful for CI: keep fountain.yml in source control, run fountain apply -f fountain.yml from your deploy pipeline"), so the first apply of a manifest containing a Webhook writes a live HMAC signing secret into the build log, where it is retained by the CI provider and readable by anyone with log access. fountain webhooks create is an interactive command, so it never had this property. The secret is not otherwise recoverable, so simply suppressing it is not an option; this is a trade-off worth stating rather than a defect in the code as written.
    Finding: 53eed83bad49d5dc335c02facd8e469d8f93e10721d2f087dc32084566f84a42
    Evidence: cli/internal/cmd/apply.go:164-171 prints r.Secret to stdout inside renderApplyResults, unconditionally and with no TTY check. apps/fountain/priv/help/manifest.md:145 ("Useful for CI: keep fountain.yml in source control, run fountain apply -f fountain.yml from your deploy pipeline") and docs/cli.md's apply section both present apply as the pipeline command. cli/internal/cmd/webhooks.go:155-177 and :294-306 show the interactive webhooks create path this mirrors.
    Suggested remediation: A product call on how an unattended apply should hand over a one-time credential. Options: (a) leave as is and add one line to the Webhook paragraph of docs/cli.md and apps/fountain/priv/help/manifest.md warning that a create in CI puts the secret in the build log, and pointing at fountain webhooks rotate-secret for recovery; (b) print the secret only when stdout is a TTY and otherwise print a pointer telling the operator to rotate the secret interactively; (c) add an explicit --show-secrets flag that CI must opt into. Option (a) is the smallest and needs no code change.
    Disposition: Reviewer requests a human decision

Usage: 101034 reported tokens. Approval does not merge the PR.

Maintainers can post new top-level PR comments: /review-loop resolve 0fb3bee4-3442-4fd2-9235-43b30028147d FINDING_ID reason or /review-loop reject-fix 0fb3bee4-3442-4fd2-9235-43b30028147d FINDING_ID reason. Use a unique finding ID prefix of at least 12 characters. Decisions do not dismiss human replies. After recording decisions, start a fresh review with /review-loop retry 0fb3bee4-3442-4fd2-9235-43b30028147d. Edited comments do not execute.

# Only once the new binding is the committed one: a machine torn down
# against a write that then failed would be rebuilt for nothing.
# Ownership: established above, by the scoped get_teammate.
_ = Conversations._unsafe_retire_orphaned_homes(orphans, "teammate_rebound", opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high · fix · qa-team

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: c31f87205fcabf9da98d7b78ab377317422e29a215701b34a82cc69c2da8954b

Rebinding one teammate leaves its sandbox_id pointing to the retired shared home. If another live conversation shares that home, the next wake moves both conversations onto whichever environment/vault the waking conversation names. Thus waking the teammate first moves the other conversation onto the new binding; waking the other conversation first moves the teammate back onto the old binding. The database environment_id/vault_id fields remain different from the machine actually used. This can expose the wrong environment files and vault material to a conversation.

Evidence

write_bindings/5 updates only the teammate conversation and calls reset_sandbox through _unsafe_retire_orphaned_homes. reset_sandbox preserves all conversations and their sandbox_id. Team.send_message/5 resumes these live conversations through ConversationServer.send_prompt. Conversations.create_fresh_sandbox_and_start/4 builds the replacement from the waking conversation environment/vault and then unconditionally calls move_cotenants/3 (conversations.ex:3299). That function updates every other live cotenant sandbox_id without comparing bindings (3355-3382). The new retirement test checks only that the old sandbox terminates, with no second conversation or subsequent wake.

Suggested remediation

In apps/fountain/lib/fountain/conversations.ex, restrict replacement cotenant migration to conversations whose effective environment and vault match the replacement sandbox identity; leave conversations with a different identity for their own wake. Add a regression scenario with two idle conversations sharing a home, rebind one, and wake them in both orders, asserting each uses its declared identity.

Disposition

Blocking finding requires remediation

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 c31f87205fca reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 c31f87205fca reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

do: {:ok, conv, :unchanged}

defp write_bindings(user_id, conv, changes, orphans, opts) do
case Conversations.update_conversation(conv, changes) do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · needs_human · qa-team

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: 0bfeb7943fa6d1bd1f5b97a17e9c0c153199f2b87f540cafe1f6907b76a144f8

When the same agent already has a live persistent home for the requested destination environment/vault, applying the teammate binding reports updated and retires its current home, but subsequent messages cannot wake the teammate. The wake path tries to insert another home for the destination identity instead of attaching to the existing one, and the unique home constraint rejects it. Repeating the manifest reports unchanged and does not recover the teammate.

Evidence

An agent can have homes for different environment/vault pairs (_unsafe_find_home/4). update_teammate changes the conversation binding but retains sandbox_id. After retirement, Team.send_message uses wake_conversation for the still-live conversation. create_fresh_sandbox_and_start/4 inserts a persistent sandbox with the new environment/vault (conversations.ex:3257-3271), without looking up an existing destination home or recovering a home uniqueness conflict. Sandbox.changeset/2 enforces sandboxes_home_identity_index. The added tests never create a destination home and never send a message after rebinding.

Suggested remediation

Define and implement destination-home handling before accepting a rebind: either attach to the existing home using the normal ownership, readiness and quota checks, or refuse the rebind before mutating anything when that destination is occupied. Reuse preserves the persistent-home contract; refusal is simpler but restricts valid bindings. This needs maintainer choice and coordinated lifecycle regression coverage beyond a narrow automatic fix. Test rebinding with both source and destination homes already present, then sending a message.

Disposition

Reviewer requests a human decision

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 0bfeb7943fa6 reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 0bfeb7943fa6 reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high · fix · security-audit

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: fe815dae40a8ea57431a08dd7b7ab09415076e9e624ef2f9805abec05f9e560a

The newly supported Webhook kind lets a sprite- or principal-scoped token perform webhook management that the dedicated webhook API reserves for full-scoped keys. Untrusted sandbox code, including code induced by prompt injection, can POST a Webhook document to /api/apply with an attacker-controlled HTTPS URL and event_types ["*"]. This creates an account-wide subscription and returns its signing secret. Subsequent conversation and agent IDs, parent IDs, turn IDs, lifecycle states, timestamps and durations leave the tenant through that endpoint. Delivery persists after the originating sandbox token expires or is revoked; the endpoint has no dependency on that credential. The payload does not include transcript or environment secret values.

Evidence

router.ex routes /api/apply through accepts_json + api (lines 485-486, 530), whereas /api/webhooks explicitly uses require_full_scope (lines 292-293). TenantAPIAuth assigns the authenticated key but does not enforce full scope. ApplyController.create passes only user.id and audit attribution into Manifest; apply_webhook and reconcile_webhook call Webhooks.create_endpoint/update_endpoint without checking the credential. ApiKey.may_manage_keys?/1 permits only full; sprite and principal are excluded. Webhooks.conversation_and_endpoints joins all active endpoints by conversation user_id, and WebhookDelivery.perform checks endpoint existence/status only, not the originating key. Static trace establishes the bypass; attempted targeted tests could not start because Hex/dependencies are absent. Runtime denied-path and second-tenant checks therefore remain unexecuted.

Suggested remediation

In apply_controller.ex, before calling Manifest.apply_manifest, enforce the existing RequireFullScope policy when the submitted resources include a Webhook document, and stop on a halted connection. Keep manifests containing only the existing ordinary resource kinds accessible under their existing policy. This restores the dedicated webhook API boundary within one permitted file. Verify sprite and principal requests are denied before any writes, full-scoped creation succeeds, and a second tenant cannot update the first tenant's endpoint even when using the same URL.

Disposition

Blocking finding requires remediation

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 fe815dae40a8 reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 fe815dae40a8 reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

action: %Schema{type: :string, enum: ["created", "updated", "error"]},
action: %Schema{
type: :string,
enum: ["created", "updated", "unchanged", "error"],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high · needs_human · product-api

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: 3e2bff5d2e0c897c98a52e3b35f5764c88da84e4a81e9778f5e6fead441a4d8d

POST /api/apply now returns action="unchanged" for any row whose record already matched the document, and by design that is the common case: an idempotent re-apply returns "unchanged" for every row (apps/fountain/test/fountain_web/controllers/apply_controller_test.exs asserts List.duplicate("unchanged", 6), and docs/cli.md now says "A second apply of an unchanged manifest reports unchanged for every row"). The only shipped client that branches on this value is the Go CLI, and every already-released fountain binary has no "unchanged" case. Adding a value to a response enum is a distinct contract from adding an optional field: the new secret property is ignored safely by old decoders, but a widened enum is not. The contract and conformance suites cannot catch this — CONTRIBUTING.md says both "compare a schema with another schema", and the regenerated sdk/contract/contract.json plus sdk/typescript/src/generated/openapi.ts agree with the server precisely because they were regenerated from it. Nothing in this PR (CHANGELOG.md, docs/cli.md, docs/api.md, apps/fountain/priv/help/manifest.md) tells an operator that the released CLI must be upgraded before the server ships.

Evidence

The call shape is renderApplyResults in the base CLI, git show 539a300:cli/internal/cmd/apply.go lines 145-163:

switch r.Action {
case "created": fmt.Printf("%s + %s\n", label, r.Name)
case "updated": fmt.Printf("%s ~ %s\n", label, r.Name)
default:
anyFailed = true
warnf("%s ! %s: %s", label, r.Name, formatResultErrors(r.Errors))
}

and runApply (cli/internal/cmd/apply.go:68-70) does if renderApplyResults(results) { os.Exit(1) }. formatResultErrors returns the literal string "apply failed" when errs is empty, and an unchanged row carries errors: nil (Fountain.Manifest.result/6, apps/fountain/lib/fountain/manifest.ex:562). Server side, verdict/2 (apps/fountain/lib/fountain/manifest.ex:549-555) returns :unchanged whenever the schema fields minus timestamps compare equal, and ApplyJSON renders it verbatim (apps/fountain/lib/fountain_web/controllers/apply_json.ex:12). So an operator on a pre-#1636 binary running the pipeline that docs/cli.md and apps/fountain/priv/help/manifest.md both recommend ("keep fountain.yml in source control, run fountain apply -f fountain.yml from your deploy pipeline") gets, on the second and every later run:

env ! my-project: apply failed
vault ! alice: apply failed
agent ! researcher: apply failed

on stderr and exit status 1, against a manifest that applied cleanly and a server that wrote nothing wrong. The Swift SDK is unaffected (sdk/swift/.../Account.swift declares action as a plain String), and the TypeScript change is types only, which is why the CLI is the whole blast radius.

Suggested remediation

This is a product decision the approved base does not settle, so it needs the maintainer rather than an automatic fix. Three alternatives: (1) ship the widened enum and state the incompatibility explicitly — a CHANGELOG note under a Changed/Breaking heading and a line in docs/cli.md saying a fountain binary older than this release reports every unchanged row as a failure and exits nonzero — and sequence the CLI release ahead of the server deploy; (2) keep the wire value at "updated" unless the caller opts in, e.g. an unchanged: true field on ApplyRequest or an explicit client-capability header, so old binaries keep the behavior they were built against and the new CLI gets the = output; (3) narrow the blast radius by having the server emit "unchanged" only for the three new kinds, which no released CLI can send. Whichever is chosen, say it in the release notes: the two schema-to-schema gates will stay green either way.

Disposition

Reviewer requests a human decision

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 3e2bff5d2e0c reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 3e2bff5d2e0c reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

Comment thread apps/fountain/lib/fountain/manifest.ex
Comment thread apps/fountain/lib/fountain/manifest.ex Outdated
Comment thread apps/fountain/priv/help/manifest.md
Comment thread apps/fountain/lib/fountain/manifest.ex
Comment thread apps/fountain/lib/fountain/manifest.ex
Comment thread apps/fountain/lib/fountain/manifest.ex
Comment thread apps/fountain/lib/fountain/manifest.ex Outdated
Exception.format(:error, error, __STACKTRACE__)
)

{result(kind, str(name), :error, %{"base" => [Exception.message(error)]}, []), nil}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · fix · xp-reviewer

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: d77a775fcd08c26ef45f784146924c0e50b8681a6e0dcbf1d3c73c1a7d9641be

guarded/3 already logs the full exception and stacktrace, which is the useful record. It then also puts Exception.message(error) (rescue branch) and inspect(reason) (catch branch) verbatim into the result row's errors, and FountainWeb.ApplyJSON.result/1 serializes errors straight into the 200 response, which fountain apply then prints. That turns any unexpected crash anywhere in a pass into client-visible internal detail of unbounded size. It also weakens an invariant this very module states and tests: apply_manifest/3's docs say "Secret values are never echoed back", and apply_controller_test.exs asserts it — but the Environment and Vault passes handle plaintext secret values inside guarded/3, and Elixir's own messages for MatchError, CaseClauseError, BadMapError and ArgumentError embed the offending value. The guard itself is well motivated (a crash after environments/vaults/agents are committed would otherwise hand the caller a 500 and no results); only the choice to forward the text is the problem.

Evidence

apps/fountain/lib/fountain/manifest.ex:151-166 builds the error map from Exception.message(error) / inspect(reason) immediately after Logger.error(...) has already captured the same information. apps/fountain/lib/fountain_web/controllers/apply_json.ex:15 renders errors: result.errors unmodified. apps/fountain/lib/fountain/manifest.ex:81 (Secret values are never echoed back in the moduledoc, retained by this PR) and apps/fountain/test/fountain_web/controllers/apply_controller_test.exs:"never echoes secret values back" state the invariant, which only holds on the non-raising paths. apps/fountain/test/fountain/manifest_test.exs asserts the leaked text is public: assert errors == %{"base" => ["the sandbox provider fell over"]}.

Suggested remediation

In guarded/3 keep the two Logger.error calls exactly as they are and return a fixed, non-reflective message in the result row instead of the raised text — e.g. %{"base" => ["apply failed unexpectedly; see the server log"]} for both branches. Update the one manifest_test assertion that pins the raised string to assert the fixed message instead. Single file plus its test; the documented behaviour (the row fails, the request still returns 200 with the other rows) is unchanged.

Disposition

Blocking finding requires remediation

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 d77a775fcd08 reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 d77a775fcd08 reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

case Conversations.update_conversation(conv, changes) do
{:ok, updated} ->
fields = for {key, field} <- @bindings, Map.has_key?(changes, field), do: key
record(user_id, "team.updated", updated, opts, %{"fields" => fields})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium · needs_human · xp-reviewer

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: 6dfc40754c14bd8b432b7825384414c2cc2e6ff468f3c1bf2a69ee608c537a1e

update_teammate/4 writes the same column, with the same blank-trimming and the same "only if it actually moved" rule, as the existing rename_teammate/4: both set the teammate conversation's title and both record metadata %{"fields" =&gt; ["name"]}. They disagree only on the action string. A teammate renamed through PATCH /api/team/:agent_id records team.renamed; the identical rename applied from a Teammate document records team.updated. ADR 0013 calls action "the trail's only groupable column" and notes Fountain.Analytics mirrors it through, so an operator or dashboard filtering on team.renamed silently misses every apply-driven rename — the exact door-dependent coverage gap CLAUDE.md's audit section says the #540 campaign existed to remove. The new team.updated action is justified for the environment/vault bindings, which rename_teammate/4 does not cover; it is only the overlapping name field that is now recorded two ways, along with two copies of the write-and-detect-a-real-change logic.

Evidence

apps/fountain/lib/fountain/team.ex:422-440 (rename_teammate/4: blank_to_nil, Conversations.update_conversation(conv, %{"title" =&gt; title}), if updated.title != conv.title, record(user_id, "team.renamed", updated, opts, %{"fields" =&gt; ["name"]})) versus apps/fountain/lib/fountain/team.ex:504-596 (update_teammate/4: @bindings maps "name" -&gt; :title, binding_changes/2 applies the same blank_to_nil and the same reject-if-equal, record(user_id, "team.updated", updated, opts, %{"fields" =&gt; fields})). Both then call broadcast_changed(user_id). Fountain.Manifest is the only non-test caller of update_teammate/4 (apps/fountain/lib/fountain/manifest.ex:321), and apps/fountain/test/fountain/manifest_test.exs exercises a rename-only apply that lands on team.updated.

Suggested remediation

A maintainer decision, because either direction moves a recorded action string. Option A: have update_teammate/4 emit team.renamed when changes is exactly %{title: _} and team.updated otherwise, so the existing action keeps its meaning and no route's trail changes — smallest change, but two action strings for one function. Option B: make rename_teammate/4 delegate to update_teammate(user_id, agent_id, %{"name" =&gt; name}, opts) so there is one write path — cleaner, but it retires team.renamed from PUT/PATCH /api/team/:agent_id and from team_controller.ex:95's documented behaviour, which is a visible change to the audit trail and to analytics grouping. Option C: accept the split and document it. Whichever is chosen, fold the duplicated title-write into one place and update audit_guardrail_test.exs, team_test.exs and team_controller_test.exs accordingly.

Disposition

Reviewer requests a human decision

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 6dfc40754c14 reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 6dfc40754c14 reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

Comment thread apps/fountain/priv/help/manifest.md
Comment thread apps/fountain/lib/fountain/manifest.ex
{:ok, vault_id} <- resolve_ref("vault", user_id, spec["vault"], refs.vaults) do
attrs = %{"name" => name, "environment_id" => env_id, "vault_id" => vault_id}
reconcile_teammate(user_id, name, agent_id, attrs, opts)
else

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low · needs_human · xp-reviewer

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: ae8e3540572f841e4006a972be0879bf703c8f547bf3c0589db77d1719e21c6b

The PR adds unclaimed/3 for Teammate documents and explains exactly why: two documents that describe one record make every pass rewrite the other's, so no apply is ever unchanged. A Schedule is keyed by name under its teammate and has the same property, but no equivalent guard. Two Schedule documents with the same metadata.name and the same teammate — a plausible copy-paste in a hand-written manifest — resolve to the same row: the first document creates or updates it, the second immediately updates it back, and the same flip-flop repeats forever. Every CI apply then reports created/updated rather than unchanged and writes two team.schedule.updated audit rows, which is precisely the audit noise this PR set out to remove in its ### Fixed entry. Nothing is corrupted and the row converges to whichever document is last, so the cost is a permanently noisy trail and an apply that never reports a clean state.

Evidence

apps/fountain/lib/fountain/manifest.ex:283-296 (unclaimed/3, with the comment "without this the second renames the first's conversation on every pass and the manifest is never idempotent") has no counterpart in apply_schedule/4 at apps/fountain/lib/fountain/manifest.ex:328-341. reconcile_schedule/5 (manifest.ex:343-357) finds the row with Schedules.list_schedules(user_id, agent_id) |&gt; Enum.find(&amp;(&amp;1.name == name)), so both documents select the same schedule. reconcile/3 (manifest.ex:136-144) does pass a claimed map to every callback, but apply_schedule/4 ignores it and reconcile_schedule/5 returns nil as the id, so nothing is ever claimed for this kind. apps/fountain/test/fountain/manifest_test.exs covers the duplicate-Teammate case ("a manifest with a duplicate Teammate is still idempotent for the rest") and has no duplicate-Schedule case.

Suggested remediation

A maintainer decision on the intended semantics, and the reason it is not a one-liner: a Schedule's real key is the pair (teammate, name), not the document name that reconcile/3 claims by, so two same-named schedules under different teammates are legitimate and must keep working. Option A (matches the Teammate precedent): have apply_schedule/4 take the claimed argument reconcile/3 already hands it, key it on {agent_id, name} rather than the document name, and refuse the second document with %{"name" =&gt; ["is already used by another Schedule document for this teammate"]}; this needs reconcile/3's accumulator to carry that pair, which fits inside manifest.ex. Option B: declare last-write-wins for schedules, leave the code as is, and say so in the Teammate/Schedule section of apps/fountain/priv/help/manifest.md so a reader is not led by the Teammate rule to expect a refusal. Either way, add a duplicate-Schedule case beside the existing duplicate-Teammate test.

Disposition

Reviewer requests a human decision

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 ae8e3540572f reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 ae8e3540572f reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

changeset
|> Repo.update()
|> audited("environment.updated", merge_metadata(opts, Audit.changed_fields(changeset)))
result = Repo.update(changeset)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low · needs_human · xp-reviewer

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: 2f23c82e473f28ea47a614cb5caf69b0d8d80414a34eaa549d60f53f0ff94b55

The behaviour change is right and matches CLAUDE.md's "Only record what happened ... a no-op sync records nothing", and the CHANGELOG documents it. The shape is the concern: if changeset.changes == %{} is now written out four times in four contexts, each with a comment pointing at Fountain.Environments.update_environment/3 as the canonical copy, and Fountain.Team.Schedules.update_schedule/3 already carried a fifth. Three of the four are structured as result = Repo.update(...) then an if/else that returns result or audited(result, ...), and the fourth (webhooks) as a bare if inside a case, so the same rule reads differently in each place. The next context to gain an update function has nothing to call and will copy a fifth variant, and there is no single place a reader can check which contexts observe the rule.

Evidence

apps/fountain/lib/fountain/environments.ex:135-152, apps/fountain/lib/fountain/vaults.ex:109-120, apps/fountain/lib/fountain/agents.ex:186-193 and apps/fountain/lib/fountain/webhooks.ex:123-135 all guard on changeset.changes == %{} (or != %{}); three of them carry the comment "A save that moves nothing records nothing, the same rule Fountain.Environments.update_environment/3 follows (#1636)". apps/fountain/lib/fountain/team/schedules.ex:122 has the pre-existing if changeset.changes != %{} form.

Suggested remediation

Add one helper next to the audit plumbing — e.g. Fountain.Audit.changed?/1 or an audited_if_changed(result, changeset, action, opts) shape each context's private audited/3 can wrap — and have the five call sites use it, so the rule and its comment live in one place. This spans five files and so exceeds the one-file fix budget; it is also worth a maintainer's call whether the helper belongs in Fountain.Audit or stays as a per-context idiom, given each context's audited/3 is private and resource-typed.

Disposition

Reviewer requests a human decision

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 2f23c82e473f reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 2f23c82e473f reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

Comment thread cli/internal/cmd/apply.go
// A webhook endpoint's signing secret comes back on the apply that
// created it and never again, so print it where the reader is looking.
if r.Secret != "" {
fmt.Printf(" signing secret %s %s\n", r.Name, r.Secret)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

low · needs_human · xp-reviewer

Evaluated revision: 7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3 · Finding: 53eed83bad49d5dc335c02facd8e469d8f93e10721d2f087dc32084566f84a42

Showing a webhook signing secret once at creation is the established design, and fountain webhooks create prints it the same way, so this is consistent rather than novel. What is new is the surface: fountain apply is the one command the docs explicitly recommend wiring into an unattended pipeline ("Useful for CI: keep fountain.yml in source control, run fountain apply -f fountain.yml from your deploy pipeline"), so the first apply of a manifest containing a Webhook writes a live HMAC signing secret into the build log, where it is retained by the CI provider and readable by anyone with log access. fountain webhooks create is an interactive command, so it never had this property. The secret is not otherwise recoverable, so simply suppressing it is not an option; this is a trade-off worth stating rather than a defect in the code as written.

Evidence

cli/internal/cmd/apply.go:164-171 prints r.Secret to stdout inside renderApplyResults, unconditionally and with no TTY check. apps/fountain/priv/help/manifest.md:145 ("Useful for CI: keep fountain.yml in source control, run fountain apply -f fountain.yml from your deploy pipeline") and docs/cli.md's apply section both present apply as the pipeline command. cli/internal/cmd/webhooks.go:155-177 and :294-306 show the interactive webhooks create path this mirrors.

Suggested remediation

A product call on how an unattended apply should hand over a one-time credential. Options: (a) leave as is and add one line to the Webhook paragraph of docs/cli.md and apps/fountain/priv/help/manifest.md warning that a create in CI puts the secret in the build log, and pointing at fountain webhooks rotate-secret for recovery; (b) print the secret only when stdout is a TTY and otherwise print a pointer telling the operator to rotate the secret interactively; (c) add an explicit --show-secrets flag that CI must opt into. Option (a) is the smallest and needs no code change.

Disposition

Reviewer requests a human decision

Human replies stay open until addressed. Resolving a conversation alone does not approve the PR.

Maintainers: post a new top-level PR comment /review-loop resolve e1ee3a9d-7d18-43de-833f-2446d4273f43 53eed83bad49 reason or /review-loop reject-fix e1ee3a9d-7d18-43de-833f-2446d4273f43 53eed83bad49 reason. Then retry the ended run with /review-loop retry e1ee3a9d-7d18-43de-833f-2446d4273f43.

lex00 and others added 8 commits September 6, 2026 13:24
…ents (#1636)

One manifest now declares a whole estate. `POST /api/apply` takes three
more kinds after the existing three, reconciled in a fixed order:
Environment, Vault, Agent, Teammate, Schedule, Webhook. A spec names
another document by name and the server resolves it, against the manifest
first and then against the tenant's own records, the way an Agent's
`environment` already did.

  * Teammate: keyed by the document name, which is what the teammate is
    called; `agent`, `environment` and `vault` resolve by name. Adding one
    opens the teammate's conversation through `Fountain.Team.add_teammate/4`.
    Re-applying moves the name and the environment and vault bindings
    through a new `Fountain.Team.update_teammate/4`, which gates both ids on
    the agent's allowlists and records `team.updated` only when something
    moved. It provisions no second computer.
  * Schedule: keyed by the document name under its `teammate`, with the
    `TeamScheduleCreateRequest` fields. It goes through
    `Fountain.Team.Schedules`, so an invalid cron fails with the create
    route's own changeset error.
  * Webhook: keyed by `spec.url`, with the `WebhookEndpointCreateRequest`
    fields. `Fountain.Webhooks.create_endpoint/3` hands the signing secret
    back on the apply that mints it; the result row carries it once and an
    update never does.

Result rows gain a fourth verdict, `unchanged`, for every kind: a record
that already matched the document was not written to, so a second apply of
the same file says so instead of claiming an update. Inline `spec.secrets`
are re-encrypted on each apply and keep reporting `upserted`, because the
stored ciphertext cannot be compared with the plaintext given.

Apply stays additive. A document dropped from the manifest leaves its
record in place; there is no prune, and none is added here.

The CLI groups and orders the six kinds, prints `=` for `unchanged`, and
prints a new endpoint's signing secret once. A server too old for
`/api/apply` cannot reconcile the three new kinds, so the fallback path
reports them and exits nonzero rather than dropping them silently.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
…mate moves its computer (#1636)

Review of the bulk-apply branch found five defects. All five are here.

An `unchanged` row still left an `*.updated` audit event. `Repo.update` on an
empty changeset skips the SQL, but the four contexts audited the `{:ok, _}`
either way, so an idempotent re-apply wrote six rows saying a record nobody
had touched was updated, each naming an empty changed-field list. That is
what CLAUDE.md's "only record what happened" forbids, and what the OpenAPI
description and the manual already claimed did not happen. `Environments`,
`Vaults`, `Agents` and `Webhooks` now gate the recording on a non-empty
changeset, the way `Team.Schedules.update_schedule/3` already did.

`Team.update_teammate/4` rebound a live conversation with no regard for the
machine underneath it. A home is keyed on (user, agent, environment, vault),
so moving either id leaves the running computer holding the old environment's
secrets while the row names the new one, and orphans the home: the next
launch looks under the new key, builds a fresh machine, and the old one stays
`ready` holding a concurrency slot. This is the hazard #1084 made
`Agents.update_agent/3` refuse, and it is refused the same way now, with
`:sandbox_mid_turn` before anything is written and `reset_sandbox/2` on the
orphan after. An ephemeral computer is a conversation's own and is left
alone; a name-only change touches nothing.

Two Teammate documents naming one agent described one record, so each pass
renamed the other's conversation and the manifest was never idempotent. The
second and any later document now fail with their own error, and so does a
second document reusing a name, which a Schedule could not have resolved.

Nothing isolated an exception. The Teammate pass reaches Horde, the sandbox
quota lock, the credit gate and the sandbox provider, and a raise there
abandoned a call that had already committed the environments, vaults and
agents, leaving the caller a 500 and no rows at all, against a moduledoc
promising best effort per resource. Each document's reconcile is now wrapped,
and a raise or an exit becomes that row's error with a logged stacktrace.

The audit assertions covered only the create path. They now cover the update
path too, for `team.updated`, `team.schedule.updated` and
`webhook_endpoint.updated`, with the request's actor and IP. Those webhook
actions keep the `webhook_endpoint` prefix the webhook routes have always
written rather than the `webhook` one the issue named, because renaming a
live audit vocabulary breaks every trail already recorded; the deviation is
stated in the manual.

Also, from the same review: a Teammate document is read as a whole
declaration, so an absent `environment` or `vault` clears that binding, which
now says so in all three places a reader looks; a Schedule naming a teammate
two of them answer to fails rather than binding to whichever the roster
listed last; the known context refusals get sentences instead of `inspect/1`
output; result-row error keys are strings whichever branch produced them; and
`update_teammate/4` takes the teammate map a caller already holds so bulk
apply lists the roster once per document rather than twice.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
sobelow flagged the interpolated atom in binding_allowed/4; the two
refusals are now literal atoms passed by the caller.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
)

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
… invoking RequireFullScope for any manifest containing a Webhook before calling Manifest.apply_manifest. Return the halted connection immediately on refusal, preventing writes to all resources in a denied mixed manifest. Manifests without Webhooks retain their existing scope policy; full-scoped requests retain the existing tenant-scoped reconciliation. The trusted max_files=1 policy limits this proposal to the webhook finding. Findings c31f87205fcabf9da98d7b78ab377317422e29a215701b34a82cc69c2da8954b (cotenant identity) and d77a775fcd08c26ef45f784146924c0e50b8681a6e0dcbf1d3c73c1a7d9641be (exception disclosure) remain unresolved and need separate proposals. No test execution is claimed.

<!-- review-loop-effect:e1ee3a9d-7d18-43de-833f-2446d4273f43:7afa4ef65fd0eecd94c4409e999b2ced1a43fdd3:publish -->

Signed-off-by: managoat-review-loop[bot] <325469270+managoat-review-loop[bot]@users.noreply.github.com>
The review loop found that a Webhook document let a sandbox-scoped token
create an endpoint through apply, which POST /api/webhooks refuses. Its
fix is the commit before this one; these are the tests, including that a
refused mixed manifest writes none of its other resources.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
…at is taken (#1636)

Two findings from the review of #1675, both real.

A rebinding retired the teammate's home but left every conversation on it
still naming that row, co-tenants included. The next wake re-provisioned from
whichever conversation woke first and `move_cotenants/3` then repointed the
others at it without looking at what they declared, so waking the teammate
first pulled its co-tenant onto the new environment and vault, and waking the
co-tenant first pulled the teammate back onto the old ones. Either way a
conversation ran on a machine built from another conversation's binding,
holding that binding's environment files and vault material, while its own
row said something else. Co-tenants only diverge because rebinding makes them
diverge — attaching to a machine requires the same identity — so this is the
rebinding's bill to pay.

`_unsafe_list_cotenants_with_identity/2` now reads each co-tenant's effective
pair, the conversation's own override falling back to its agent's
environment, which is what a sandbox row carries and what
`_unsafe_find_home/4` keys on. Only the ones that match the replacement
follow onto it. One that named something else keeps pointing at the retired
row, which its own wake reads as `:create_new` and builds from its own pair.
Every co-tenant is still told the disk is gone, because for every one of them
it is; the ones left behind are told why they did not follow.

The second finding is an occupied destination. There is one live home per
(user, agent, environment, vault), and the wake path builds a home rather
than attaching to one, so writing a binding whose identity already has a home
left a teammate that could not wake at all: the insert hit
`sandboxes_home_identity_index`, and re-applying the same manifest reported
`unchanged` and did not recover it. `update_teammate/4` now refuses that
before anything is written, with `:destination_home_occupied`, which
`Fountain.Manifest` maps to a per-row sentence naming the fix.

Refusing is deliberately the conservative half of the choice the reviewer put
to a maintainer. Merging the teammate onto the machine that is already there
is the other half, and it needs the readiness, runtime-shape and quota checks
`attach_conversation/3` makes and this function does not. Refusing cannot
strand a teammate; attaching wrongly can. Both halves of the decision are
stated in `docs/api.md`, `docs/cli.md` and the manifest help topic, so
whoever wants the merge later can see what was decided and why.

The retirement test asserted only that the old row terminated. It now has a
co-tenant and a subsequent wake, in both orders, at the `Conversations` level
and end to end through `Team.update_teammate/4`, plus the occupied-destination
refusal at both levels. The three co-tenant cases were checked against the old
`move_cotenants/3` and fail there.

Also formats the line the review-loop commit left over the limit, which
`mix format --check-formatted` was failing on.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
…#1636)

guarded/3 logged the exception and then also put its message into the
result row, which ApplyJSON serialises into the 200 body. The environment
and vault passes hold plaintext secrets inside that rescue, and Elixir's
MatchError, CaseClauseError, BadMapError and ArgumentError messages embed
the value they choked on, so the module's own promise that secret values
are never echoed back held only on the paths that did not raise.

Signed-off-by: lex00 <121451605+lex00@users.noreply.github.com>
@lex00
lex00 force-pushed the issue-1636-apply-kinds branch from 0268a67 to 18c091f Compare September 6, 2026 20:44
@lex00

lex00 commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

All three findings are addressed in the commits just pushed, and the branch has been rebased onto current main.

On the blocking co-tenant identity finding: move_cotenants/3 now takes the replacement sandbox rather than its id, and only repoints co-tenants whose effective environment and vault match the replacement's. Effective means coalesce(conversation.environment_id, agent.environment_id), which is the pair _unsafe_find_home/4 keys on. A co-tenant that declared something else is stranded rather than moved: its session id is cleared, its sandbox_id deliberately keeps naming the retired row so its own wake reads it as terminated and builds from its own pair, and it gets the same reset event and transcript line, because the disk is gone for it too. Regressions wake the two conversations in both orders and assert each ends on a sandbox carrying the identity it declared. Reverting the fix makes all three fail.

On the finding that asked for a decision, an occupied destination home: it refuses. update_teammate/4 computes the destination identity before writing anything, and returns :destination_home_occupied when a live home already exists for it that is not the machine the teammate is on. Bulk apply renders that as a per-row message telling the caller to reset or remove the existing computer first. The reasoning is that attaching to the machine already there needs the readiness, runtime-shape and quota checks attach_conversation/3 makes and this function does not, and that refusing cannot strand a teammate while attaching wrongly can. It is written down in docs/api.md, docs/cli.md, the help topic and the function's docstring, so the other half stays available to whoever wants it. There is a test that a restated identical binding is not treated as a move, so the guard cannot over-refuse.

On the exception disclosure finding, which was mine to cause: the rescue now puts a fixed sentence in the result row and nothing else. The logs are unchanged. The reviewer was right that this was more than noise, because the environment and vault passes hold plaintext secrets inside that rescue and Elixir's own MatchError, CaseClauseError, BadMapError and ArgumentError messages embed the value they choked on, so the module's promise that secret values are never echoed back held only on the paths that did not raise. The test that pinned the leaked string now asserts the fixed message and that the raised text is absent.

The webhook scope commit is kept as pushed, with tests: a sandbox-scoped token is refused on a manifest containing a Webhook, the other resources in that manifest are not written, a manifest without one still applies, and a full-scope key gets its secret once.

@jhgaylor

Copy link
Copy Markdown
Collaborator

Split into a stack of eight PRs, per the rule against large PRs. This branch stays open as the reference — its body and review threads are the record of why each decision was made — and closes when #1806 merges.

Each PR is based on the one above it; review and merge top down:

PR Scope Diff
#1799 fix: a save that changes nothing records nothing, and apply says so — closes #1680 +177/−60
#1800 fix(api): a raise in one apply document fails that row, not the request +120/−21
#1801 fix(conversations): a co-tenant follows a replacement only if it declared the same identity +186/−35
#1802 feat(team): update_teammate/4 rebinds a teammate and moves its computer +569/−3
#1803 refactor(cli): group apply documents by kind (no behaviour change) +73/−40
#1804 feat(api): bulk apply reconciles Teammate documents +814/−113
#1805 feat(api): bulk apply reconciles Schedule documents +297/−48
#1806 feat(api): bulk apply reconciles Webhook documents — closes #1636 +483/−59

Nothing was dropped. git diff issue-1636-apply-kinds stack/1636-webhook-kind over manifest.ex, team.ex and apply_json.ex is empty: the tip reproduces this branch's modules byte for byte. The extra lines are the CHANGELOG entries each PR earns and a handful of tests the split made possible — the exception-isolation case in #1800 reaches the raise through the Agent pass rather than the Teammate pass, because Teammate does not exist yet at that point in the stack.

Two deliberate changes from this branch:

Verified on the tip, not just per PR: 6 doctests and 4,761 tests with 0 failures; mix compile --warnings-as-errors, mix format --check-formatted and mix credo --strict (7,054 mods/funs, no issues) clean; scripts/sdk-contract/build.sh --check ok; go test, go vet and gofmt clean in cli/; the TypeScript SDK suite green. Each PR below was also run against its own targeted suites before being committed, and the three tests in #1801 plus the one in #1800 were confirmed to fail with their fix reverted.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WQyc3wWWDAeZJE1Vr6etQa

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:api HTTP API, OpenAPI spec, CLI contract enhancement New feature or request needs-human-review Review Loop requires a human decision or further verification

Projects

None yet

2 participants