Design note for the rank-4 enrollment command family: contract composition (contract 5 rank-4 row + envelope, contract 3 §3.5, custody-schema rev 4 FK binding), assignment-scope pin per D11, measured current state, command surface with closed error enum, additive schema delta plan (0021), and the witness list the implementation slice must ship. Amends no contract.
11 KiB
kind, status, audience
| kind | status | audience |
|---|---|---|
| spec | active | 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 rev 4 (agent-grantee FK bound to the live agents
table), 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:
- 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)."
- 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.
- 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.
- Custody-schema rev 4: contract 7's agent-grantee FK references
the live
agentstable (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.
2. Current state (measured 2026-08-29 at origin/next = 94d626df)
agentstable (packages/dbschema.ts): id uuid PK, name, provider, model, status enum, project_id (legacyprojects, 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.tsrepository, consumed by the legacy/api/agentsCRUD controller (apps/gateway/src/agent/agent-configs.controller.ts). - 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. 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.
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 | 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) |
Rules:
- 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 }. - Intake = the existing sealed store.
intakewrites throughProviderCredentialsService.store(seal-at-rest, upsert per (userId, provider)); enrollment persists no second copy and no plaintext. - Reference must resolve.
referencewith no stored credential for (actor, provider) refuses withprecondition_failed(nothing is created). - 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_systemis never settable through this command. - Idempotency (§4.3). Replay with the same key and same actor
returns the original result; same key with a different payload
refuses with
conflict. - 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.
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).
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).
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 (idempotency-key
payload mismatch) · precondition_failed 422 (unresolvable credential
reference; unknown harness beyond syntactic validation) ·
internal_fault 500.
4. Schema delta (migration 0021, additive-only)
Extend agents — no new agent table, preserving custody-schema rev 4's
FK binding without amendment:
harnesstext NULL — registered harness name; NULL for pre-existing rows (legacy rows predate the concept).enrolled_attimestamptz NULL — set byagent.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, 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).
Witnesses (real PostgreSQL, lane standard): append-only enforcement, same-tx atomicity (agent row + audit + outbox all-or-nothing under injected failure), idempotency uniqueness.
Sequencing: additive DDL via the same migration path as 0018–0020 (hierarchy). The KBN-101 §5.3 gate binds KBN-100 kanban DDL, not this lane; if the pending ASKS-5 ruling changes migration mechanics lane-wide, re-check before generating 0021.
5. Witnesses the implementation slice must ship
- Never-echo: enroll via
intake, assert the value string is absent from the HTTP result, the audit row, the outbox payload, and captured logs. - Sealed-store single-copy: after intake, the credential exists only in
provider_credentials(sealed), andagentshas no credential column at all. - Reference-resolution refusal (
precondition_failed, no row created). - Unknown-harness refusal against the live registry.
- Idempotent replay +
conflicton payload mismatch. - Same-tx atomicity fault injection (agent/audit/outbox).
- Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
call → zero rows in
agents/agent_audit_events/agent_outboxattributable to the family. is_systeminjection attempt is rejected by DTO validation.
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).