---
type: Decision
title: "Decision 0010 — Source and credential model (git-centric, real usage)"
description: "A git-centric source model with deploy and fleet keys, and state-only versus run-in-server workspaces."
status: stable
tags: [decision, sources, credentials]
---

# Decision 0010 — Source and credential model (git-centric, real usage)

Status: accepted · 2026-08-22 · amended 2026-08-25 (source-kind collapse, see addendum)

## Context

Early releases shipped `playbook run <local-path>` and `workspace add --dir <local-dir>` —
runs point at a path on the box. That is a demo shape, not how Ansible and
OpenTofu are actually operated. Real usage is git-source-centric and has three
credential planes the current design under-specifies: git access, secret
decryption, and reaching the managed fleet. Review already flagged fragments
(inventory undesigned; host onboarding; per-host become/ssh).

## Decision — the real flows

### Ansible

1. **Source is a registered git repo**, not a path. `exocomp source add
   <name> --url git@forge:org/repo.git [--ref main] [--signed]`. exocomp
   generates an ed25519 **deploy key**, prints the public half to add to the
   forge (or `--key <sealed-secret>` mounts an existing one, sealed into the
   envelope). It clones into the data volume and **auto-syncs** — on a schedule
   and/or a push webhook — pinning to a ref/tag the control plane advances only
   after a gated run (the ansible-pull trust anchor).
2. **Vault is registered, not passed.** Vaulted files live in the repo; the
   operator registers the vault-id password (`exocomp secrets add
   ansible-vault/<project>` or derives it) mapped to a `--vault-id label`.
   Multiple ids per repo (dev/prod) are first-class.
3. **Managed-host SSH is a credential plane.** exocomp holds an ed25519 fleet
   key sealed in the envelope. *Runtime status (v1):* the key is generated and
   sealed and `host onboard` records each host's key into a backed-up
   known_hosts store, but a run does not yet load the fleet key into a per-job
   ssh-agent nor set `UserKnownHostsFile`/`StrictHostKeyChecking` from that
   store — the v1 connection path is a vaulted `ansible_ssh_private_key_file` /
   `ansible_password` in group_vars (secret in the repo vault, decrypted by the
   envelope). The agent-load + known_hosts-verify runtime is v1.1. As designed,
   a per-job `ssh-agent` loads it from memory
   (never disk). Hosts are onboarded by a seeded runbook (push key, create
   `ansible_user`, sudoers, record `known_hosts`); `known_hosts` is stored and
   backed up. `become_pass`/`ansible_ssh_pass` come from sealed rows bound per
   inventory group, never plaintext in the repo. Connection matrix documented
   (Linux/SSH, network_cli, ProxyJump; non-SSH appliances out of scope).
4. **Run** executes from the synced checkout with the vault-id, the fleet key,
   sandboxed, redacted, through the one queue.

### Source locality — three kinds, git is the headline not the only one

A source is one of: a **registered git repo** (headline — auto-synced, signed,
deploy-keyed, as above); a **host path** bind-mounted into the container (the
operator already keeps a repo checkout on the box or on the NAS and points
exocomp at it — no deploy key, sync is the operator's own `git pull` or an
editor); or an **in-container path** in the data volume (seeded by a runbook, or
an operator working directly). The credential planes (vault, fleet key, provider
env) are identical across all three — only *how the tree arrives and stays fresh*
differs. Host and in-container paths are the quick/air-gapped path and what the
tests use; git is the managed path. The sandbox profile grants the source tree
read (host bind mounts are read-only by default); `source add --path <p>` and
`--url <git>` are the two registration forms, and `--dir` on a run is shorthand
for an ad-hoc in-container path.

### OpenTofu — two modes, both first-class

- **Mode A · secure state store only.** `exocomp workspace add <name>
  --state-only` emits a ready-to-paste `backend "http"` block plus its bearer;
  the operator runs `tofu` themselves (laptop, CI) and state lands encrypted
  and locked in exocomp's SQLite. exocomp never runs the plan. This is the
  lowest-commitment on-ramp and must be a documented, first-class path — it is
  the "I trust you with my state, not my applies yet" adoption door.
- **Mode B · run in server.** Source in a registered repo (or `--dir` for
  local), exocomp runs plan/apply through the queue against its own backend,
  the plan is a saved encrypted artifact, apply consumes exactly it, optionally
  approval-gated. State and runs both live in exocomp.

## Consequences

New surface (`source add|list|sync|rm`, deploy-key + fleet-key
generation, `known_hosts` store, the host-onboarding runbook, `workspace add
--state-only` + backend-config emit) and the inventory/adoption docs. The
`repo` table sketched earlier is realised here. The local-path forms stay for
quick use and tests but are no longer the headline flow. This is a scope
increase over the original brief and is accepted as correctness, not
gold-plating: the tool is not usable for real Ansible/Terraform without it.

## Addendum — 2026-08-25: two kinds, writable as its own bit

The three source kinds collapse to two: `git` and a single `path`. `hostpath`
and `volpath` never described a behavioural difference — outside a container
there is no bind mount, and inside one a host path *is* the data volume — and the
code confirmed it: the read-only flag the original text promised for host paths
was defined but never enforced (the sandbox write-granted every source tree
unconditionally). A `path` source takes one `--path` argument, absolute or
relative to the data volume, and read/write is now a first-class `writable`
column rather than a side effect of the kind name. It defaults read-only: the
least-privilege posture, matching that the old `--kind` default was already the
read-only one, so running playbooks out of a tree keeps working unchanged while
write becomes an explicit `--writable`.

The read-only default is now honestly enforced: a read-only source's working
directory is granted read, never write, in the sandbox profile — closing the gap
the original text described but the executor never held. The one genuine safety
rule, that a source path may not be an ancestor of the data dir, is unchanged;
a relative path resolves under the data volume and is structurally guaranteed to
stay inside it (empty, `.`, and any `..` component are rejected).

Migration 0027 rebuilds the `source` table, narrowing the kind CHECK to
(`git`, `path`) and mapping existing rows: `hostpath` → `path` read-only,
`volpath` → `path` writable, `git` unchanged. Because `inventory` references
`source` with `ON DELETE CASCADE` and foreign keys cannot be disabled inside a
migration transaction, the rebuild renames the old table aside and rebuilds
`inventory` too, so the cascade never fires and no inventory is lost.
