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:
co-authored by
Claude Sonnet 5
parent
e1983438b9
commit
c18e203ad1
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user