Files
stack/docs/plans/2026-08-29-agent-enrollment-command-design.md
T
fred d18ce04959
ci/woodpecker/pr/ci Pipeline was successful
design rev 3: GLM round-2 findings
shared replay mode refused (seed-only rule, contract 3 SS4.3/SS7 item 4);
concurrency loser semantics and scope-mismatch collision added to rule 5
and witness 5; query-side correlation envelope specified; no-oracle
indistinguishability probe added to witness 9; fence authorization
scope pinned; SHARED-CONTRACT SS5.3 citation fixed, fleet-side ASKS
reference removed.
2026-08-29 20:00:54 -05:00

20 KiB
Raw Blame History

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 §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.tsbrain.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).