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.
20 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 §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:
- 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 — 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.
- 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 §5.2 (rev 13; binding introduced at 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.
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)
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 (the only module issuinginsert(agents)), with three write consumers: the legacy/api/agentsCRUD controller (apps/gateway/src/agent/agent-configs.controller.ts), the/agent newchat 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:
- 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, inside the transaction.
intakewrites through the sealed-store path (ProviderCredentialsService.storesemantics: 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. - 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 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 (alwaysactor-boundfor this family — thesharedrefusal 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 boundedconflictshape — 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. - 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:
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 — 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 0018–0020 (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
- 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). - Harness refusals, both codes: syntactically invalid →
validation_failed; well-formed registry miss →precondition_failed(against the live registry). - 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
conflictshape; a replay is re-authorized fresh (a submitter whose authorization was revoked since the original is refused, not replayed); ashareddeclaration onagent.enrollis refusedvalidation_failedwith 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). - 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.
- Wizard-facing zero-mutation witness (contract 3 §6.10 shape): no
call → zero rows in
agents/agent_audit_events/agent_outbox/agent_idempotency_fenceattributable to the family. is_systeminjection attempt is rejected by DTO validation.- Correlation-id witness (contract 5 §6.3): a correlation id submitted
on
agent.enrollappears in its audit event(s) and in the result; the same holds foragent.enrollment.get's result; the §6.3 static companions (noany-typed boundary pass-through; single audit emitter) apply. §6.3's no-existence-oracle probe: an unauthorizedagent.enrollment.getof an existing agent and a get of a nonexistent id return indistinguishable results. - CLI-parity witness (contract 5 §6.4): a CLI smoke invocation of
agent.enrollandagent.enrollment.getagainst 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). - 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).