Files
stack/docs/plans/2026-08-29-agent-enrollment-command-design.md
2026-08-30 01:21:00 +00:00

316 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
kind: spec
status: active
audience: developer
---
# Agent Enrollment Command Family — v1 Design (M4-4-0)
Status: design note (implementation-facing; amends no contract).
Authority chain: tool-gateway-mapping.md §3.1 rank-4 row + §4 envelope
(ruled 2026-08-27), onboarding-wizard.md §3.5 (D11 minimal enrollment),
custody-schema.md §5.2 at revision 13 (agent-grantee FK bound to the
live `agents` table — a binding introduced at rev 4 and standing
verbatim), PRD §9 D11. Where this note and a ratified contract disagree,
the contract wins.
## 1. What the contracts bind (and what they leave open)
There is no standalone enrollment contract. The rank-4 family is defined
by composition:
1. **Contract 5 §3.1 rank 4:** "Enroll one agent: harness, credential
reference/API-key intake (values never echoed), name/persona,
assignment scope (contract 3 §3.5)."
2. **Contract 5 §4 — all five sub-clauses:** §4.1 typed request/result
DTOs validated at the Gateway boundary (expected-version only where
an owning contract defines one); §4.2 closed per-family error enum
(validation, authentication, authorization, not-found, conflict,
precondition, internal) with HTTP mappings; §4.3 audit linkage — the
envelope contributes correlation: every request accepts/generates a
correlation id, carried into the audit events **and returned in the
result**, with no second audit stream; §4.4 fail-closed — an
operation that cannot evaluate its authorization or reach its owning
tool refuses, never degrading to a fallback read or direct data
access; §4.5 CLI parity — the family MUST be invocable through the
official CLI against the same Gateway commands with the same
request/result/error contracts (a Gateway command without CLI
exposure is a tracked conformance gap).
**Idempotency keys are NOT contract 5 §4.3:** the idempotency-key
envelope is contract 3 §4.3, ratified as a drafting addition to
contract 5 §4's command envelope via contract 3 §7 item 4. Its fence
and replay rules bind as written there; §3.1 rule 5 below designs to
them.
3. **Contract 3 §3.5:** the wizard's enrollment step is minimal (one
harness, API-key login, agent name and persona — D11), uses ONLY this
family, and is skippable. Wizard witness §6.10: a run that skips the
step produces zero enrollment-family mutations.
4. **Custody-schema §5.2 (rev 13; binding introduced at rev 4):**
contract 7's agent-grantee FK references the live `agents` table
(`agents.id`, uuid); an enrollment surface with its own table would
force a contract-7 amendment.
**Assignment scope (open point, pinned here):** the rank-4 row cites
contract 3 §3.5, which defines no assignment semantics; the PRD's full
enrollment vision (Part I, Standalone flow) includes "account
assignment", but the D11 v1 slice is exactly "one harness, API key,
name/persona". v1 therefore scopes assignment to the two bindings the
minimal slice already implies — the enrolling user becomes the agent's
owner (`agents.owner_id`), and the credential reference names which of
that user's stored provider credentials the agent uses. Richer
assignment (multi-account, comms auto-enroll, workspace placement) is
deferred with the rest of the PRD's full flow (D11); when a contract
defines it, this family extends by ordinary amendment of the design.
The deferral rests on contract 3 §3.5's explicit delegation of
enrollment specifics to this family — not on reading the D11 list as
exhaustive (it is not: the §3.1 `model`/`provider` fields are required
by the live table's NOT NULL columns, though D11 does not name them).
## 2. Current state (measured 2026-08-29 at `origin/next` = `94d626df`)
- `agents` table (packages/db `schema.ts`): id uuid PK, name, provider,
model, status enum, project_id (legacy `projects`, ON DELETE SET
NULL), owner_id → users, system_prompt, allowed_tools, skills,
is_system, config jsonb, timestamps. No harness column (provider and
model describe the LLM backend, not the harness), no audit coupling.
- Sole write path: `packages/brain/src/agents.ts` repository (the only
module issuing `insert(agents)`), with three write consumers: the
legacy `/api/agents` CRUD controller
(`apps/gateway/src/agent/agent-configs.controller.ts`), the `/agent
new` chat command (`apps/gateway/src/commands/command-executor.service.ts`
`brain.agents.create`), and workspace bootstrap
(`apps/gateway/src/workspace/project-bootstrap.service.ts`). All
three keep serving existing consumers; none is touched by M4-4.
- Sealed credential store exists: `ProviderCredentialsService`
(apps/gateway/src/agent/) — one row per (userId, provider), values
sealed at rest, decrypt server-side only, summaries never carry
values.
- Harness registry exists (`apps/gateway/src/harness/`), the validation
source for the harness field.
- Implementation pattern: the merged hierarchy module (M4-1) —
transaction-scoped command context, in-tx authorization, discriminated
result unions, same-transaction semantic audit event + transactional
outbox, no-oracle not_found folding.
**F1 — contract-5 mapping note (disposition, not an amendment):**
`/api/agents` appears nowhere in contract 5 — neither as a P0 row nor in
the §3.2 legacy non-substitutes list (the ruled §3.2 freeze names
specific endpoints, and `/api/agents` is not among them). The operative
constraints are §3.3's amendment-only rule for new mapping rows and §5's
closure rule: this design adds no new consumer to `/api/agents` and
builds the rank-4 family as the P1 path for enrollment. Adding the
missing P0 row is a contract amendment for a future S2 pass; nothing in
M4-4 depends on it.
## 3. Command family surface (v1)
One command, one query. Module: `apps/gateway/src/enrollment/`
(`enrollment.module.ts`), mirroring the hierarchy module's shape.
### 3.1 `agent.enroll` (mutation)
Request DTO (shared types package, class-validator at the boundary):
| Field | Type | Rule |
| ---------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness` | string | syntactically invalid (empty/malformed) → `validation_failed`; well-formed but not in the harness registry → `precondition_failed` |
| `correlationId` | string (uuid) | optional; generated when absent (contract 5 §4.3); carried into audit events and returned in the result |
| `replayMode` | 'actor-bound' | optional, default `actor-bound`. `shared` is seed-only (contract 3 §4.3 binds it to the §3.4 canonical seed key set and "no other operation can carry a shared declaration"; §7 item 4 closes it); a `shared` declaration here is refused `validation_failed`, executes nothing, and records no fence row |
| `name` | string | non-empty, trimmed, ≤ 200 chars |
| `persona` | string \| null | optional; stored as the agent's system prompt |
| `model` | string | non-empty (provider-qualified model id) |
| `provider` | string | non-empty; names the credential's provider |
| `credential` | discriminated union | `{ mode: 'reference' }` — a credential for (actor, provider) MUST already exist; `{ mode: 'intake', type: 'api_key', value: string }` — value is sealed into the credential store in the same flow |
| `idempotencyKey` | string (uuid) | required (contract 3 §4.3, ratified into contract 5 §4 via contract 3 §7 item 4) |
Rules:
1. **Never echoed.** The credential value appears in no result DTO, no
audit event, no outbox payload, and no log line. The result carries
only `{ provider, credentialMode }`.
2. **Intake = the existing sealed store, inside the transaction.**
`intake` writes through the sealed-store path
(`ProviderCredentialsService.store` semantics: seal-at-rest, upsert
per (userId, provider)) **in the same transaction** as the agent
insert — a failure after the credential write rolls everything back,
leaving no orphan credential. Enrollment persists no second copy and
no plaintext.
3. **Reference must resolve.** `reference` with no stored credential for
(actor, provider) refuses with `precondition_failed` (nothing is
created).
4. **Ownership.** `owner_id` = the authenticated actor. v1 authorization
is AuthGuard-authenticated user; no hierarchy grant is required
because v1 enrollment binds no hierarchy node (§1 assignment-scope
pin). `is_system` is never settable through this command.
5. **Idempotency fence (contract 3 §4.3, in full).** The command layer
records, in a uniqueness-constrained fence table in the same
transaction as the mutation and its audit event: the key, the
operation identifier (`agent.enroll`), the acting principal, the
authorization scope, a digest of the canonicalized request payload
(the digest input EXCLUDES the credential value — it covers
provider + credentialMode, never plaintext), the declared replay
mode (always `actor-bound` for this family — the `shared` refusal
in the table above means no shared fence row can exist here; the
column is kept for envelope-shape fidelity and mode-mismatch
collision checks), and a reference to the committed outcome (the
agent id). The recorded **authorization scope** for this family is
pinned to the acting principal's platform-user scope (v1
authorization is grant-free per rule 4, so the scope is the
authenticated-user identity domain — recorded so the §4.3
scope-equality check has a defined value). Fence uniqueness is the
pair (operation identifier, key). **Replay:** a submission whose
(operation, key) is recorded is first authorized exactly as a fresh
submission; then replay-mode, scope, and digest equality are
checked (a mismatch on any — including scope — is a collision);
then **target-result authorization** — the submitter must hold, at
replay time, read authority on the referenced agent row under
§3.2's rule (owner or admin) — plus recorded-actor equality
(`actor-bound`). A passing replay executes nothing, returns the
recorded outcome, and appends a replay access event (non-mutation
audit class: accessing principal, current correlation id,
fence-row reference). Any equality or authorization failure refuses
with the single bounded `conflict` shape — constant, identifying no
record — preserving the no-existence-oracle rule. **Concurrency
(contract 3 §4.3's rule, ratified via §7 item 4):** two submissions
with the same (operation, key) serialize on the fence's unique
constraint — exactly one executes; the loser waits for the winner's
transaction, and is then handled as a replay if it committed
(through the full replay path above) or executes afresh if it
aborted. A unique-violation race never surfaces as an unhandled
internal fault.
6. **Audit + outbox, same transaction.** Insert into `agents` +
sealed credential write (intake mode) + fence row + semantic audit
event (`agent.enrolled`: actor, agent id, harness, provider, name,
credentialMode — no credential material) + outbox row commit
atomically, hierarchy-pattern style. Audit rows reference the agent
by **snapshot id, not FK** — mirroring the hierarchy audit tables'
deliberate FK-free linkage so audit history survives agent deletion
through the legacy CRUD DELETE path.
Result union: `enrolled { agent, correlationId }` | refusal from the
§3.3 enum (refusals also carry the correlation id, per contract 5
§4.3's end-to-end traceability). `agent` in the result is the persisted
row minus nothing sensitive (the table stores no credential material).
### 3.2 `agent.enrollment.get` (query)
By agent id; actor must be the owner (or admin). Unauthorized and
missing fold to the same `not_found` wire shape (contract 2
no-existence-oracle rule, applied family-wide for uniformity).
The query carries the same non-state envelope as the mutation
(contract 5 §4.3; contract 3's envelope reconciliation confirms closed
query responses carry it): typed request DTO with an optional
`correlationId` (generated when absent) and a typed result —
`found { agent, correlationId }` | `not_found` (the folded shape,
also carrying the correlation id). Queries take no idempotency key
(the fence binds mutations).
### 3.3 Error enum (closed, §4.2)
`validation_failed` 400 · `authentication_failed` 401 ·
`authorization_refused` 403 (owner-only paths; folded to `not_found`
where §3.2 applies) · `not_found` 404 · `conflict` 409 (the single
bounded idempotency refusal shape of §3.1 rule 5) · `precondition_failed`
422 (unresolvable credential reference; well-formed harness not in the
registry — syntactic invalidity is `validation_failed` per the §3.1
table) · `internal_fault` 500 (also the §4.4 fail-closed class when the
owning tool is unreachable; unauthorized-fallback behavior is
prohibited).
## 4. Schema delta (migration 0021, additive-only)
Extend `agents` — no new agent table, preserving custody-schema §5.2's
FK binding without amendment:
- `harness` text NULL — registered harness name; NULL for pre-existing
rows (legacy rows predate the concept).
- `enrolled_at` timestamptz NULL — set by `agent.enroll`; NULL marks a
legacy (non-enrolled) row. No backfill: enrollment is a fact this
command creates, not one to invent for existing rows.
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
separate store): `agent_audit_events` (append-only: id, event_type,
actor id, agent id — snapshot value, no FK, per §3.1 rule 6 —
correlation id, causation id, payload jsonb, created_at; per-agent
ordering index), `agent_outbox` (hierarchy-outbox shape), and
`agent_idempotency_fence` (contract 3 §4.3 shape: operation identifier,
key, acting principal, authorization scope, canonicalized-payload
digest, replay mode, committed-outcome reference (agent id), created_at;
UNIQUE (operation identifier, key)). Persona reuses the existing
`system_prompt` column; no version column (no ratified expected-version
rule names `agents` — §4.1 binds only where the owning contract defines
one).
Witnesses (real PostgreSQL, lane standard): append-only enforcement,
same-tx atomicity (agent row + credential write + fence row + audit +
outbox all-or-nothing under injected failure at multiple points,
including after the credential write), fence uniqueness on
(operation, key).
Sequencing: additive DDL via the same migration path as 00180020
(hierarchy). The docs/native-kanban-sot/SHARED-CONTRACT.md §5.3 DDL
gate binds the kanban lane's audit/proposal DDL, not this lane; if a
pending operator ruling on migration sequencing changes mechanics
lane-wide, re-check before generating 0021.
## 5. Witnesses the implementation slice must ship
1. Never-echo: enroll via `intake`, assert the value string is absent
from the HTTP result, the audit row, the outbox payload, and captured
logs.
2. Sealed-store single-copy: after intake, the credential exists only in
`provider_credentials` (sealed), and `agents` has no credential
column at all.
3. Reference-resolution refusal (`precondition_failed`, no row created).
4. Harness refusals, both codes: syntactically invalid →
`validation_failed`; well-formed registry miss →
`precondition_failed` (against the live registry).
5. Idempotency (contract 3 §4.3 set): actor-bound replay returns the
recorded outcome and executes nothing (no new agent/audit/outbox
mutation rows; a replay access event is appended); payload-digest
mismatch, replay-mode mismatch, scope mismatch, and different-actor
actor-bound replay each refuse with the single bounded `conflict`
shape; a replay is re-authorized fresh (a submitter whose
authorization was revoked since the original is refused, not
replayed); a `shared` declaration on `agent.enroll` is refused
`validation_failed` with nothing executed and no fence row
recorded (seed-only rule); two concurrent same-(operation, key)
submissions produce exactly one mutation, the loser resolving
through the replay path (no unhandled unique-violation fault).
6. Same-tx atomicity fault injection (agent / credential write / fence
/ audit / outbox), including a failure injected after the intake
credential write commits its statement — everything rolls back, no
orphan credential.
7. Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
call → zero rows in `agents`/`agent_audit_events`/`agent_outbox`/
`agent_idempotency_fence` attributable to the family.
8. `is_system` injection attempt is rejected by DTO validation.
9. Correlation-id witness (contract 5 §6.3): a correlation id submitted
on `agent.enroll` appears in its audit event(s) and in the result;
the same holds for `agent.enrollment.get`'s result; the §6.3 static
companions (no `any`-typed boundary pass-through; single audit
emitter) apply. §6.3's no-existence-oracle probe: an unauthorized
`agent.enrollment.get` of an existing agent and a get of a
nonexistent id return indistinguishable results.
10. CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
`agent.enroll` and `agent.enrollment.get` against the Gateway
succeeds with the same typed results the web client receives. The
implementation slice therefore SHIPS CLI exposure for both
operations (contract 5 §4.5 — a Gateway command without CLI
exposure is a tracked conformance gap; this design refuses to open
one).
11. Fail-closed witness (contract 5 §6.5): with the owning tool or
grant state unreachable (fault injection), the operation returns
the internal-fault or authorization-refusal class and performs no
fallback read/write.
## 6. Out of scope
Wizard orchestration (M4-6); any UI (D8/D12); un-enroll/update lifecycle
(no contract requires it in v1 — the legacy write surfaces named in §2
keep serving existing consumers); OAuth login, multi-account, comms
auto-enroll, model recommendation (PRD full flow, deferred by D11);
contract amendments (F1 recorded above for a future S2 pass). CLI
exposure is explicitly IN scope (witness 10 — contract 5 §4.5 binds it).