Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# A content-addressed catalog root selects admitted Agent Spec seats

Status: proposed

## Context

The recursive `agent.kdl` catalog makes declaration source, discovery path, and
mutable agent state share one directory. That is convenient for authored files,
but it cannot atomically select a fleet assembled from independently managed
Agent Specs. It also makes a dynamic agent manager choose between rewriting
Nix-managed declarations and projecting mutable symlinks into a recursively
discovered tree.

The use case is a mixed catalog: static tooling may build and pin exact Agent
Spec bytes, while an agent manager may select or roll back seats at runtime.
Messages, context, status, and future state still need stable mutable paths when
the selected declaration object changes.

This proposal does not change canonical authored KDL, the trusted-private-fleet
assumption, or the current recursive catalog. It incubates a second,
experimental resolution path with executable evidence.

## Options

| Option | Tradeoffs |
| --- | --- |
| Rewrite or symlink `agent.kdl` projections | Reuses discovery, but exposes partial multi-seat updates and conflates immutable source with mutable state. |
| One mutable head per seat | Gives seat-local CAS, but readers cannot name or validate one atomic fleet snapshot. |
| Immutable seat admissions selected by one complete catalog root | Adds object types and a globally contended root CAS, but gives one atomic visibility boundary and preserves stable resource paths. |

## Proposed Decision

Store exact Agent Spec bytes and immutable commits below the hidden
`.st2/catalog-v1` namespace. A `SeatAdmission` joins one exact Agent Spec ref
commit to one exact resource-binding commit. A parent-linked
`CatalogRootCommit` maps every bus id to its admission. After validating the
complete prospective graph, publish one mutable root head atomically.

```text
exact KDL object <- ref commit ----\
SeatAdmission <- CatalogRootCommit <- root
stable agent_dir <- binding commit /
```

`AgentSpec.path` is the immutable declaration source. `AgentSpec.agent_dir` is
the stable mutable state root used by messages, context, status, and runtime
state. Resolution does not create an `agent.kdl` projection.

Static and dynamic managers use the same protocol:

1. `prepare` imports exact bytes without changing selection.
2. `stage` publishes a ref commit and resource binding without changing the
selected root.
3. `admit` atomically selects one or many staged seats in a complete root.

Manager fencing prevents a different manager from advancing an owned ref or
admitting ref/binding commits it does not own. The root's `manager` records the
transaction actor; it does not grant whole-root custody. A manager may CAS from
another manager's current root while preserving untouched foreign admissions
byte-identically. Operation ids make an acknowledged ref/root update replayable
after response loss. Rollback is a new parent-linked commit, not a head rewind.
Manager names are logical coordination labels under the trusted same-user
assumption; they are not authentication or authorization.

## Validation Boundary

Before moving the root head, st2 resolves every admission in the prospective
root and verifies:

- every digest in the selected reachable graph and every referenced object;
- bus-id, host, identity, manager, and schema joins;
- exactly one explicit-host, explicit-identity declaration per object;
- active declarations lower to runnable Agent Specs; and
- every resource state path is a normal catalog-relative path;
- no state root is under reserved `.st2`, crosses an existing symlink
component, or is shared by two selected seats.

Readers observe the old or new complete root across the atomic head rename.
Test-scoped failpoints prove process-level visibility around that boundary and
operation replay after response loss. They do not prove power-loss durability
for every filesystem, mount, kernel, or storage device.

## Consequences and Limits

- Nix can manage immutable source objects while an agent manager owns only
admission, without requiring a private projection directory.
- Mutable messages, context, and status remain ordinary files at stable
`agent_dir` paths; they are not content addressed.
- State roots are seat-exclusive, catalog-relative, outside `.st2`, and may not
traverse an existing symlink component.
- Root publication serializes writers and copies the complete admission map.
Scaling, sharding, and compaction require evidence before changing this.
- Source-relative `render copy` inputs need a future immutable resource-bundle
contract. Inline render content works now; silently reading mutable files
adjacent to an object would weaken reproducibility.
- `prepare` content-addresses exact KDL bytes, not a self-contained closure.
Workspaces, templates, hooks, and other referenced inputs are not captured.
- "Immutable" means content-addressed protocol publication: st2 refuses
replacement and verifies selected bytes under trusted same-user store
custody. Verification and later use are not sealed into one file descriptor;
verified-FD use or stronger filesystem sealing remains future hardening.
- Validation covers the selected reachable graph. Parent links record lineage
and replay identity, but ancestor history is not recursively audited.
- Discovery integration, GC, replication, replacement semantics, daemon
sockets, typed resource contracts, and an authorization framework are
explicitly outside this proposal.

Acceptance requires the experiment record to remain green and a separate human
decision. Until then, the JSON CLI and on-disk schema are experimental.
98 changes: 98 additions & 0 deletions docs/vrs/.experiments/2026-07-29-content-addressed-catalog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Experiment: content-addressed catalog admission

Status: implemented prototype

Date: 2026-07-29

Decision under test:
[`0002-content-addressed-catalog-root-selects-admitted-seats.md`](../.decisions/0002-content-addressed-catalog-root-selects-admitted-seats.md)

## Question

Can current st2 resolve and materialize exact content-addressed Agent Spec bytes while all
mutable agent resources remain at a stable `agent_dir`, and can it publish
multiple staged seats with one atomic root visibility change?

## Prototype Surface

Library: `src/catalog_store.rs`

Experimental JSON CLI:

```text
st2 --catalog ROOT catalog prepare SPEC
st2 --catalog ROOT catalog stage SPEC --manager M --state-relative PATH \
--operation-id OP [--expected-ref COMMIT] [--binding-parent COMMIT]
st2 --catalog ROOT catalog admit REQUEST.json
st2 --catalog ROOT catalog publish SPEC --manager M --state-relative PATH \
--operation-id OP [--expected-ref COMMIT] [--expected-root COMMIT]
st2 --catalog ROOT catalog head
st2 --catalog ROOT catalog inspect
```

`publish` is only a one-seat convenience composition of `stage` and `admit`.
`prepare` imports exact bytes and changes no ref or root. `stage` changes no
catalog root. An admit request is:

```json
{
"expectedRoot": null,
"manager": "eval",
"operationId": "run-42:root",
"selections": [
{
"refCommit": "sha256-...",
"resourceBindingCommit": "sha256-..."
}
]
}
```

## Executable Claims

Focused tests cover:

1. exact-byte object preservation, content-addressed source resolution, stable
message/context/status paths, inline materialization, and absence of a
projection;
2. atomic two-seat admission plus rejection of a cross-seat join without
changing the selected root;
3. a test-scoped failure after root-commit publication leaving the old root visible,
failure after head publication leaving the new root visible, and
operation-id replay bound to its original expected parent;
4. competing ref publishers producing one CAS winner plus manager fencing.
5. a dynamic manager adding its owned seat to a Nix-authored root while
preserving the untouched Nix admission byte-identically, and rejection when
it tries to admit the Nix-owned seat itself.
6. strict digest grammar before digest-derived paths, including traversal
negatives;
7. rejection of reserved, shared, or existing-symlink-crossing state roots; and
8. inspect resolving one captured root even when the selected head changes
between root capture and graph resolution.

These are process-level atomic-visibility tests, not power-loss durability
proofs. The selected reachable graph is verified; commit ancestry is not
recursively audited.

## Result

Green in the isolated prototype worktree: 8 focused catalog-store tests, 1 CLI
transaction integration test, and all 157 library tests pass. This evidence
does not promote the proposed decision; acceptance remains a separate human
decision.

## Known Gaps

- The recursive discovery/reconcile path does not consume the experimental root.
- Full validation here means complete graph, digest, join, path, parse, and
runnable validation. The legacy validator's path-layout warnings and
host-local external filesystem checks are not yet adapted to immutable
object provenance.
- External `render copy` inputs are not bundled with the content-addressed
declaration. `prepare` stores exact KDL bytes, not a self-contained closure.
- Content-addressed publication and digest verification assume trusted
same-user store custody. Verify/use sealing through one file descriptor or
stronger filesystem mechanisms is future hardening.
- Manager strings are logical fencing labels, not authentication.
- No GC, replication, replacement API, daemon socket, typed resource contract,
public failpoint API, or authorization framework is included.
8 changes: 4 additions & 4 deletions src/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,16 @@ pub fn roster(catalog_root: &Path, this_host: &str) -> Vec<AgentRow> {
let mut rows: Vec<AgentRow> = found
.specs
.iter()
.filter_map(|s| {
let agent_dir = s.path.parent()?;
Some(AgentRow {
.map(|s| {
let agent_dir = &s.agent_dir;
AgentRow {
identity: s.bus_id(this_host),
status: status::read_state(&status::status_path(agent_dir)),
name: read_name(agent_dir),
retired: s.retired,
last_activity_ms: newest_mtime_ms(agent_dir),
inbox: inbox_count(agent_dir),
})
}
})
.collect();
rows.sort_by(|a, b| a.identity.cmp(&b.identity));
Expand Down
Loading
Loading