docs: plan and design artifacts for support organization feature

Reuses the existing identity/teams, identity/agents, and
orchestration/hierarchy scaffold directories per doc 07's documented
module placement. Key decisions: last-write-wins availability (not
optimistic locking — operational telemetry, not a durable record),
compound-unique skill upsert, cycle detection only on reparenting edits
(not creation, which can't form a cycle), and a capability-eligibility
read path that composes hierarchy scope with caller-supplied skills
while deliberately excluding availability per doc 05's own ordering.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
saqib mir
2026-09-02 17:52:33 +05:30
co-authored by Claude Sonnet 5
parent e1983438b9
commit c18e203ad1
5 changed files with 459 additions and 0 deletions
@@ -0,0 +1,70 @@
# Contract: Support Organization Admin CRUD & Capability Lookup
All admin routes gated by `fastify.authenticate` (research.md — known limitation inherited from
002/003/004/005). The capability-eligibility lookup is not gated — a read path a future
orchestration caller will use, matching 004's `/knowledge/retrieve` precedent.
## Teams
- `POST /admin/teams` — creates a team, `active: true`.
- `PATCH /admin/teams/:teamId` — body `{ name?, active? }`.
- `GET /admin/teams/:teamId` — team + its current agent roster.
- `GET /admin/teams` — all teams.
## Agents
- `POST /admin/teams/:teamId/agents` — creates an agent on that team, `active: true`.
- `PATCH /admin/agents/:agentId` — body `{ name?, teamId?, active? }`.
- `GET /admin/agents/:agentId` — agent + skills + availability.
- `GET /admin/agents?active=true&teamId=` — filtered listing.
## Agent Skills
- `PUT /admin/agents/:agentId/skills/:skillTag` — body `{ level }`. Upserts — creates the tag if
new, updates `level` in place if it already exists (FR-006).
- `GET /admin/agents/:agentId/skills` — all skill tags for the agent.
## Agent Availability
- `PUT /admin/agents/:agentId/availability` — body `{ status, workingHours, currentLoad? }`.
Upserts the agent's single current record (FR-007). `400` if `status` isn't one of
`available`/`busy`/`away`/`offline` (FR-008).
- `GET /admin/agents/:agentId/availability` — the current record, or `404` if never set.
## Hierarchy Nodes
- `POST /admin/hierarchy-nodes` — body `{ name, parentId?, order, teamId?, skills?, productScope?,
categoryScope?, priorityScope?, assignmentStrategy, slaPolicyId?, escalationPolicyId?,
entryConditions?, exitConditions? }`. `404` if `parentId` is given but doesn't resolve
(FR-009).
- `PUT /admin/hierarchy-nodes/:nodeId` — same body shape, full replace of the mutable fields.
`400 CYCLE_DETECTED` if the new `parentId` would make this node its own ancestor (FR-011).
- `PATCH /admin/hierarchy-nodes/:nodeId/activate` / `.../deactivate`.
- `GET /admin/hierarchy-nodes/:nodeId` — one node.
- `GET /admin/hierarchy-nodes/:nodeId/children` — direct children, in `order`.
- `GET /admin/hierarchy-nodes?active=true` — every node matching, unordered across the whole
tree (a caller reconstructs structure from `parentId`/`order`, or walks from a known root via
`.../children`).
## Capability Eligibility
- `GET /support-org/capability-eligibility?skills=a,b&productId=&categoryId=&priorityId=` —
`skills` required (comma-separated), the rest optional. Returns active agents on active teams
holding every skill in the combined (caller + matched hierarchy node) set (research.md).
## Guarantees (callable contract)
1. **A deactivated agent never appears in an active listing, but is always resolvable by id**
(SC-001).
2. **An agent's availability is exactly one record after any number of `PUT`s** — never zero once
set, never duplicated (SC-002).
3. **A hierarchy node creation with a nonexistent `parentId` is always rejected with `404`**,
never silently becoming a root node instead (SC-003).
4. **No hierarchy edit can create a cycle** — every `PUT` that would make a node its own ancestor
is rejected with `400`, the tree remains acyclic in 100% of cases (SC-004).
5. **A capability-eligibility query for a skill no active agent holds returns `{ data: [] }`**,
never an error (SC-005).
6. **Every hierarchy node create/edit/activate/deactivate produces exactly one `AuditLog` row**
(SC-006).
7. **The capability-eligibility lookup never excludes a capability-matching agent for being
`busy`/`away`/`offline`** — availability is never part of this lookup's filtering (FR-015).
@@ -0,0 +1,93 @@
# Phase 1 Data Model: Support Organization
All new models use `cuid()` ids. Refines `docs/06-database-schema.md`'s conceptual `Team`/
`Agent`/`AgentSkill`/`AgentAvailability`/`HierarchyNode` shapes with explicit constraints
(research.md).
## Team
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| name | String | |
| active | Boolean @default(true) | Deactivation never cascades to its agents (FR-004) |
| createdAt | DateTime @default(now()) | |
| updatedAt | DateTime @updatedAt | |
**Relations**: `agents Agent[]`.
## Agent
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| teamId | String | FK → `Team` |
| name | String | |
| active | Boolean @default(true) | Excluded from active listings when false, never deleted (FR-003) |
| createdAt | DateTime @default(now()) | |
| updatedAt | DateTime @updatedAt | |
**Relations**: `team Team`, `skills AgentSkill[]`, `availability AgentAvailability?`.
**Index**: `(teamId, active)` — the exact shape "active agents on this team" queries on.
## AgentSkill
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| agentId | String | FK → `Agent` |
| skillTag | String | Free-text capability tag, e.g. `"docuqube_pdf_conversion"` |
| level | Int | Proficiency, read by a future SKILL_BASED assignment strategy — not interpreted here |
**Constraints**: `@@unique([agentId, skillTag])` — FR-006's "update, never duplicate" guarantee
(research.md).
## AgentAvailability
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| agentId | String @unique | FK → `Agent` — the uniqueness itself is FR-007's "exactly one current record per agent" |
| status | String | `available` \| `busy` \| `away` \| `offline` (FR-008, validated at the schema/service layer, not a DB enum — consistent with every other status field in this codebase) |
| workingHours | Json | Per business calendar — opaque to this feature, consumed by a future SLA/business-calendar phase |
| currentLoad | Int @default(0) | |
| updatedAt | DateTime @updatedAt | Last-write-wins (research.md) — no `expectedVersion` |
## HierarchyNode
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| name | String | |
| parentId | String? | FK → `HierarchyNode` (self-relation); null = root node |
| order | Int | Sibling order under the same parent (FR-013) |
| teamId | String? | FK → `Team` — optional; a node need not yet point at a team |
| skills | String[] | Capability tags this node covers |
| productScope | String[] | External product ids; empty = matches every product (research.md) |
| categoryScope | String[] | Free-text, empty = matches every category |
| priorityScope | String[] | Free-text, empty = matches every priority |
| assignmentStrategy | String | Free-text reference; not validated against a real strategy enum yet (spec.md Assumptions — Phase 7) |
| slaPolicyId | String? | Free-text reference; no `SlaPolicy` table exists yet (Phase 8) |
| escalationPolicyId | String? | Free-text reference; no `EscalationPolicy` table exists yet (Phase 8) |
| entryConditions | Json? | Opaque rule expression — stored, not evaluated, by this feature |
| exitConditions | Json? | Opaque rule expression — stored, not evaluated, by this feature |
| active | Boolean @default(true) | Deactivation never cascades to children (FR-012) |
| createdAt | DateTime @default(now()) | |
| updatedAt | DateTime @updatedAt | |
**Relations**: `parent HierarchyNode? @relation("HierarchyTree")`,
`children HierarchyNode[] @relation("HierarchyTree")`, `team Team?`.
**Index**: `(parentId, order)` — the exact shape "this parent's children, in order" queries on;
`(active)` for the active-tree query.
**Cycle guarantee** (not a DB constraint, enforced in the repository's edit path — research.md):
before applying a `parentId` change, walk the new parent's ancestor chain to the root; reject if
the node being edited appears in it.
## Existing models — no changes
Doc 06's conceptual `HierarchyNode` doesn't currently carry a back-relation from `Product`/
`Category`/`Priority` (catalog module) — `productScope`/`categoryScope`/`priorityScope` stay
loosely-typed string arrays, matching `KnowledgeEntry.categoryScope`'s existing precedent (004),
not a new FK relationship this feature would otherwise have to add to three existing catalog
models for no requirement that asks for it.
+123
View File
@@ -0,0 +1,123 @@
# Implementation Plan: Support Organization
**Branch**: `006-support-organization` | **Date**: 2026-09-02 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/006-support-organization/spec.md`
## Summary
Populate `identity/teams`, replace `identity/agents`' scaffold stub, and populate
`orchestration/hierarchy` (all three exist today as either untouched or `User`-based-stub
directories) with the real `Team`/`Agent`/`AgentSkill`/`AgentAvailability`/`HierarchyNode`
domain: admin CRUD with active/inactive state (never hard deletion), skill tags with
upsert-not-duplicate semantics, a single last-write-wins availability record per agent, a
cycle-safe nestable hierarchy tree with an audited change history, and a capability-eligibility
read path for the future orchestration/assignment phase (Phase 7) to call.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), Zod. No new runtime dependency.
**Storage**: PostgreSQL via Prisma (new `Team`, `Agent`, `AgentSkill`, `AgentAvailability`,
`HierarchyNode` models per `docs/06-database-schema.md`, refined in data-model.md).
**Testing**: Vitest — unit tests for the cycle-detection algorithm and the capability-matching
predicate (both genuinely extractable pure logic, unlike 004/005's thin-Prisma-query situation
for most of their surface); integration tests for the full admin CRUD + capability-lookup flow
against a real Postgres.
**Target Platform**: Same Fastify modular monolith. Populates existing module directories:
`src/modules/identity/teams/`, `src/modules/identity/agents/` (replacing its `User`-based stub),
`src/modules/orchestration/hierarchy/` (standard module shape per doc 07).
**Project Type**: Backend service — single project.
**Performance Goals**: Not performance-sensitive — admin-configuration CRUD and an
indexed-lookup read path, no different in character from 004's retrieval query.
**Constraints**: MUST NOT delete teams/agents/hierarchy nodes (active/inactive only, FR-003/
FR-004/FR-012); MUST NOT allow a hierarchy cycle (FR-011); MUST NOT let the capability lookup
consider availability (FR-015); MUST audit every hierarchy change (FR-017).
**Scale/Scope**: Five new models, CRUD for all of them, one composed read endpoint. Explicitly
excludes: the orchestration engine, assignment strategies, SLA/escalation policy models, real
agent authentication (see spec.md Assumptions).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| I. SaaS Is the Sole Identity & Access Authority | `Team`/`Agent` are SupportHub's own support-org structure (explicitly named in Principle I's own list of what SupportHub *is* the authority for) — no SaaS identity duplicated. Supersedes a stub that queried a generic `User` model, itself never a SaaS-identity mechanism to begin with. | PASS |
| II. Configuration Over Hardcoding | The entire point of `HierarchyNode` — every field (scope, strategy reference, conditions) is admin-set data, none of it interpreted or branched on by this feature's own code. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Three modules follow the standard shape; `orchestration/hierarchy` reaches `identity/teams`' `Team` data only through Prisma's own FK (no cross-module service call needed — a node just stores a `teamId`), and exposes `teamId` as opaque data to any future `orchestration/orchestration` submodule. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI reasoning in this feature. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | Hierarchy changes write to `AuditLog` (FR-017); active/inactive state (never deletion) is itself the durable-history mechanism for teams/agents, same convention as every prior feature's publish/unpublish-style state. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Availability is deliberately last-write-wins, not the SLA/assignment class of concurrency this principle is about (research.md's explicit rationale) — no background jobs in this feature. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature doesn't touch tickets/problems. | PASS — N/A |
| Technology & Platform Constraints | Prisma + Zod only, no new dependency. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. Worth calling out against Principle III: the
decision to let `HierarchyNode.teamId` stay a plain FK rather than requiring a cross-module
service call to `identity/teams` is deliberate — a node referencing a team is exactly the kind of
relationship Prisma's own FK is the right tool for (same pattern `Ticket.productId` already uses
toward `catalog/products`), reserving cross-module service calls for cases that need actual
business logic, not just a foreign key.
## Project Structure
### Documentation (this feature)
```text
specs/006-support-organization/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ └── schema.prisma # MODIFIED — add Team, Agent, AgentSkill,
│ AgentAvailability, HierarchyNode
├── src/
│ └── modules/
│ ├── identity/
│ │ ├── teams/ # NEW (existing empty-ish stub populated)
│ │ │ └── controller/ routes/ schema/ repository/ service/ types/ mapper/
│ │ │ constants/ index.ts
│ │ ├── agents/ # REPLACED (existing User-based stub superseded)
│ │ │ └── controller/ routes/ schema/ repository/ service/ types/ mapper/
│ │ │ constants/ index.ts # + skills/ + availability/ concerns
│ │ └── customers/ # untouched — out of scope (spec.md Assumptions)
│ └── orchestration/
│ └── hierarchy/ # NEW (existing empty stub populated)
│ ├── controller/ routes/ schema/ repository/ service/ types/ mapper/
│ │ constants/ index.ts
│ └── service/cycle-check.ts # pure function, unit-tested
└── tests/
├── unit/support-org/ # cycle detection, capability-matching predicate
└── integration/ # admin CRUD + capability-lookup end to end
```
**Structure Decision**: Single project. `orchestration/hierarchy` is populated without touching
any other `orchestration/*` submodule (`routing`/`orchestration`/`assignments`/`sla`/
`escalation` remain untouched stubs) — same "build only the current phase's submodule" pattern
004 and 005 already established for `ai-support`.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
@@ -0,0 +1,52 @@
# Quickstart: Validating Support Organization
Prerequisites: migrations applied. No dependency on any earlier feature's data.
## Scenario 1 — teams and agents, deactivation doesn't delete or cascade (User Story 1)
1. Create a team. **Expected**: `active: true`.
2. Create an agent on that team. **Expected**: appears in `GET /admin/teams/:teamId`'s roster.
3. Deactivate the agent. **Expected**: absent from `GET /admin/agents?active=true`, still
returned by `GET /admin/agents/:agentId`.
4. Reactivate the agent. **Expected**: reappears in the active listing.
5. Deactivate the team. **Expected**: the agent's own `active` value is unchanged.
## Scenario 2 — skills and availability (User Story 2)
1. Add a skill tag with a level to an agent. **Expected**: appears in the agent's skill list.
2. Add the same tag again with a different level. **Expected**: one entry for that tag, updated
level — not two entries.
3. Set the agent's availability (`status: busy`, working hours, no load given). **Expected**:
`currentLoad` defaults to `0`.
4. Update the same agent's availability to `status: available`. **Expected**: `GET
.../availability` returns exactly one record, with the new status.
5. Attempt to set `status: "not_a_real_status"`. **Expected**: `400`.
## Scenario 3 — the hierarchy tree (User Story 3)
1. Create a root hierarchy node.
2. Create a child node with `parentId` set to the root. **Expected**: `GET
.../:rootId/children` returns it.
3. Attempt to create a node with a `parentId` that doesn't exist. **Expected**: `404`.
4. Edit the child node's `parentId` to point at itself. **Expected**: `400 CYCLE_DETECTED`.
5. Deactivate the root. **Expected**: absent from `GET /admin/hierarchy-nodes?active=true`; the
child remains active and unaffected.
6. Create two sibling nodes under the same parent with `order: 1` and `order: 2`. **Expected**:
`.../children` returns them in that order.
## Scenario 4 — capability eligibility (User Story 4)
1. Create two agents on active teams; give agent A skill `x`, agent B skill `y`.
2. Query `/support-org/capability-eligibility?skills=x`. **Expected**: only agent A.
3. Set agent A's availability to `offline`. Query again. **Expected**: agent A is still
returned — availability never filters this lookup (FR-015).
4. Deactivate agent A. Query again. **Expected**: empty result — never an error.
5. Create a hierarchy node scoped to `productScope: [productId]` with `skills: [z]`. Query
`/support-org/capability-eligibility?skills=x&productId=productId` for an agent who holds
both `x` and `z`. **Expected**: that agent is returned; an agent holding only `x` (not `z`) is
not.
## What "done" looks like
All four scenarios pass, and together they demonstrate every functional requirement and success
criterion in `spec.md` without needing to read the implementation to know what "correct" means.
+121
View File
@@ -0,0 +1,121 @@
# Phase 0 Research: Support Organization
## Decision: Module placement — reuse and supersede the existing scaffold stubs
- **Decision**: `Team`/`Agent`/`AgentSkill`/`AgentAvailability` live in the *existing*
`src/modules/identity/agents/` and `src/modules/identity/teams/` directories (doc 07's
documented location) — both already exist as thin scaffold stubs from the original project
setup (`agentsRepository.findAllAgents()` querying the generic `User`/`UserRole` model).
`HierarchyNode` lives in the *existing* `src/modules/orchestration/hierarchy/` directory
(currently an empty `index.ts`). This feature replaces the `identity/agents` stub's content
wholesale (real `Agent`, not a `User` filter) and populates `identity/teams` and
`orchestration/hierarchy` for the first time.
- **Rationale**: Doc 07 already places these entities in exactly these module paths — this is
the documented, correct location, not an open design choice, and reusing the existing
directories (rather than creating new ones) is the literal instruction the scaffold's own
placeholder stubs were left there for.
- **Alternatives considered**: Leaving `identity/agents`' `User`-based stub alone and adding a
parallel real `Agent` module elsewhere — rejected; `User`/`UserRole` is disconnected from this
system's real architecture (no feature built since 002-saas-integration references it;
`CustomerReference` is the real customer-identity mechanism), so the stub was never a
foundation to build on, only a placeholder to replace. `identity/customers` (the sibling stub)
is explicitly left untouched — out of scope for this feature (spec.md Assumptions).
## Decision: Availability concurrency — last-write-wins, not optimistic locking
- **Decision**: `AgentAvailability` updates are plain `upsert`s keyed on `agentId` (unique) — no
`expectedVersion` field, unlike `Ticket.status` or `KnowledgeEntry`'s version-on-edit.
- **Rationale**: Availability is frequently-changing operational telemetry (an agent's current
status/load), not a durable business record whose history needs to survive a lost race —
spec.md's Assumptions section states this explicitly. Constitution Principle VII's concurrency
requirement is scoped to SLA/assignment-class correctness problems (double-assignment, missed
breaches); a slightly stale availability read that gets overwritten a moment later by the
correct current value isn't that class of problem.
- **Alternatives considered**: Reusing the `expectedVersion`/`409` pattern from every other
mutable-shared-state model in this codebase — rejected for the reason above; would add caller
friction (every availability update needs to know the current version first) for a field that's
supposed to be cheap and frequent to update.
## Decision: Skill tags — unique on `(agentId, skillTag)`, upsert on set
- **Decision**: `AgentSkill` has `@@unique([agentId, skillTag])`; "add or update a skill" is one
upsert keyed on that compound uniqueness, never a raw insert.
- **Rationale**: FR-006 requires updating an existing tag's level to replace it, not duplicate it
— a compound unique constraint makes that a DB-enforced guarantee, not just an application-level
convention that could drift.
- **Alternatives considered**: A plain non-unique table with "find-then-update-or-create" in the
service layer only — rejected; the DB-level constraint is strictly stronger and no harder to
implement (Prisma's `upsert` on a compound unique key is the same amount of code either way).
## Decision: Hierarchy node — parent existence checked on create, cycle checked on reparent
- **Decision**: `POST` (create) validates `parentId` (if given) resolves to an existing node —
no cycle check is needed here, since a brand-new node has no descendants yet. `PUT` (edit,
including changing `parentId`) additionally walks the *new* parent's own ancestor chain up to
the root; if the node being edited appears in that chain, the edit is rejected (FR-011).
- **Rationale**: A newly created node literally cannot form a cycle (nothing points to it yet) —
checking for one there would be dead code. A reparenting edit is the only operation that can
introduce a cycle, so that's the only place the check needs to run. Walking the ancestor chain
(bounded by the tree's actual depth, never unbounded) is the standard, simplest correct
algorithm for this — no need for a more sophisticated cycle-detection structure at this scale.
- **Alternatives considered**: A materialized-path or nested-set model to make cycle detection
O(1) — rejected as unwarranted complexity for an admin-configuration tree with no performance
requirement in spec.md; the adjacency-list model doc 06 already specifies is sufficient, and
matches this codebase's general preference for the simplest structure that satisfies the actual
requirement.
## Decision: Capability-eligibility lookup — skill union via hierarchy scope, no availability filtering
- **Decision**: `GET /support-org/capability-eligibility?skills=a,b&productId=&categoryId=&priorityId=`
(all query params free-text/external-reference strings, no FK validation against
`catalog/products`/`categories`/`priorities` — same loosely-typed-scope convention
`KnowledgeEntry.categoryScope` already established in 004). Resolution:
1. Find active `HierarchyNode`s whose `productScope`/`categoryScope`/`priorityScope` each
either is empty (matches anything) or contains the given value (spec.md Edge Cases: an
unconfigured scope is "match on skill alone," not "match nothing").
2. Union every matched node's own `skills` into the caller's requested `skills` set.
3. Return active agents, on active teams, whose own `AgentSkill` tags are a superset of that
combined skill set (FR-014) — presence only, proficiency `level` is not filtered here
(doc 05 §4 SKILL_BASED is a later assignment-strategy concern that reads `level` for
weighting, not an eligibility gate).
4. Never filters or reorders by `AgentAvailability` (FR-015, doc 05 §3's explicit "capability
before availability" ordering) — a `busy`/`offline` agent who has every required skill is
still returned.
- **Rationale**: This is FR-014/FR-015/Acceptance Scenario 5 (US4) made concrete: hierarchy scope
composes with, rather than replaces, the caller-supplied requirement, and the lookup stops at
"who is capability-eligible" — never touching availability or making an assignment decision,
which doc 05 §8 reserves for orchestration's own `engine`/`rules`/`strategies` structure (a
later, genuinely different feature).
- **Alternatives considered**: Requiring the caller to pre-resolve which hierarchy node applies
and pass its id directly — rejected; FR-014's own wording ("composing... when the context
resolves to one") makes resolving the applicable node this lookup's job, not the caller's,
matching how 004's retrieval resolves product/feature/category itself rather than making the
caller pre-filter.
## Decision: Hierarchy changes are audited via a dedicated per-module writer, reusing `AuditLog`
- **Decision**: `src/modules/orchestration/hierarchy/repository/hierarchy-audit-log.repository.ts`
exports a `writeHierarchyAuditEvent(...)` function, structurally identical to
`catalog/products`' existing `writeIntegrationAuditEvent` — one `AuditLog` row per create/edit/
activate/deactivate, `entityType: 'HierarchyNode'`.
- **Rationale**: FR-017/doc 07 explicitly require hierarchy changes to be audited, and this
codebase already has exactly one precedent for writing to `AuditLog` (002-saas-integration's
integration lifecycle events) — reusing that same shape (a small, module-owned writer function,
not a shared generic "auditable" abstraction nothing has asked for) is the least surprising
choice.
- **Alternatives considered**: A shared cross-module `AuditService` — rejected; only one other
module in this codebase writes to `AuditLog` at all, and duplicating a ~15-line writer function
a second time is simpler than introducing a new shared abstraction two call sites don't yet
justify.
## Decision: Admin endpoint authentication — reuse the existing stub
- **Decision**: Every admin CRUD endpoint in this feature (teams, agents, skills, availability,
hierarchy nodes) is gated by `fastify.authenticate`, the same known-limitation stub every prior
feature's admin surface uses. The capability-eligibility read endpoint is not gated — it's a
read path a future internal caller (orchestration) will use, matching 004's
`GET /knowledge/retrieve` precedent of leaving its own future-caller read path ungated while
admin CRUD stays behind the stub.
- **Rationale**: Consistency with established precedent; this feature does not build real agent
authentication (spec.md Assumptions) or touch `identity/auth`.
- **Alternatives considered**: None — direct reuse of existing, already-accepted conventions.