docs: agent enrollment command family v1 design (M4-4-0) #1481

Merged
fred merged 3 commits from feat/m4-4-0-enrollment-design into next 2026-08-30 01:21:01 +00:00
Showing only changes of commit 7bc69286d1 - Show all commits
@@ -9,8 +9,9 @@ audience: developer
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 rev 4 (agent-grantee FK bound to the live `agents`
table), PRD §9 D11. Where this note and a ratified contract disagree,
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)
@@ -21,17 +22,33 @@ 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:** typed request/result DTOs validated at the Gateway
boundary; closed per-family error enum (validation, authentication,
authorization, not-found, conflict, precondition, internal) with HTTP
mappings; §4.3 audit with trace correlation and idempotency keys.
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 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.
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
@@ -44,6 +61,10 @@ 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`)
@@ -52,9 +73,14 @@ defines it, this family extends by ordinary amendment of the design.
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, consumed
by the legacy `/api/agents` CRUD controller
(`apps/gateway/src/agent/agent-configs.controller.ts`).
- 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
@@ -68,11 +94,13 @@ defines it, this family extends by ordinary amendment of the design.
**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. Under Ruling 5's freeze, this
design treats it as a frozen legacy surface: no new consumer is added to
it, and the rank-4 command family is 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.
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)
@@ -83,25 +111,30 @@ One command, one query. Module: `apps/gateway/src/enrollment/`
Request DTO (shared types package, class-validator at the boundary):
| Field | Type | Rule |
| ---------------- | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `harness` | string | MUST name a registered harness (harness registry); else `validation_failed` |
| `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 (§4.3) |
| 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' \| 'shared' | optional, default `actor-bound` (contract 3 §4.3) |
| `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.** `intake` writes through
`ProviderCredentialsService.store` (seal-at-rest, upsert per
(userId, provider)); enrollment persists no second copy and no
plaintext.
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).
@@ -109,17 +142,41 @@ Rules:
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 (§4.3).** Replay with the same key and same actor
returns the original result; same key with a different payload
refuses with `conflict`.
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 (`actor-bound` default / `shared`), and a reference to the
committed outcome (the agent id). 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; 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) — in every mode, with
recorded-actor equality as the additional `actor-bound` condition.
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.
6. **Audit + outbox, same transaction.** Insert into `agents` +
semantic audit event (`agent.enrolled`: actor, agent id, harness,
provider, name, credentialMode — no credential material) + outbox row
commit atomically, hierarchy-pattern style.
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 }` | refusal from the §3.3 enum.
`agent` in the result is the persisted row minus nothing sensitive (the
table stores no credential material).
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)
@@ -131,14 +188,17 @@ no-existence-oracle rule, applied family-wide for uniformity).
`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 (idempotency-key
payload mismatch) · `precondition_failed` 422 (unresolvable credential
reference; unknown harness beyond syntactic validation) ·
`internal_fault` 500.
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 rev 4's
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
@@ -149,15 +209,22 @@ FK binding without amendment:
New tables, mirroring the hierarchy audit/outbox pair (pattern reuse,
separate store): `agent_audit_events` (append-only: id, event_type,
actor id, agent id, correlation id, causation id, idempotency key,
payload jsonb, created_at; per-agent ordering index) and `agent_outbox`
(hierarchy-outbox shape). 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).
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 + audit + outbox all-or-nothing under
injected failure), idempotency uniqueness.
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 KBN-101 §5.3 gate binds KBN-100 kanban DDL, not this
@@ -173,18 +240,45 @@ lane-wide, re-check before generating 0021.
`provider_credentials` (sealed), and `agents` has no credential
column at all.
3. Reference-resolution refusal (`precondition_failed`, no row created).
4. Unknown-harness refusal against the live registry.
5. Idempotent replay + `conflict` on payload mismatch.
6. Same-tx atomicity fault injection (agent/audit/outbox).
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, 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).
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`
attributable to the family.
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 §6.3 static companions (no `any`-typed boundary pass-through;
single audit emitter) apply.
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 frozen legacy CRUD keeps 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).
(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).