106 Commits
Author SHA1 Message Date
saqibmir da11dbc961 Merge pull request 'development' (#18) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/18
2026-09-10 09:19:18 +00:00
saqibmir d78bf52182 Merge pull request '016-load-concurrency-testing' (#17) from 016-load-concurrency-testing into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/17
2026-09-10 07:00:21 +00:00
saqib mirandClaude Sonnet 5 20e5493798 docs(016-load-concurrency-testing): polish — findings, task completion
Documents implementation-time findings in the requirements checklist: all
three suspected races were confirmed real then fixed, the ticket-status
mechanism needed no fix, a real pre-existing test-infrastructure issue
(throwaway DB ticket-code collisions at high accumulated volume) was found
and resolved by resetting the throwaway database and replaying its full
migration history, two full-suite-only integration failures were confirmed
as pre-existing cross-file contamination (not a regression), and the
load-test tooling surfaced a real Anthropic API cost consideration for
ticket creation itself. All 23 tasks marked complete.

Full quality gate green: typecheck, lint, architecture check, full unit
suite (119/119), full integration suite against a freshly reset throwaway
database (122/124 — the 2 failures are the project's own already-accepted
MinIO baseline), and all 6 concurrency test files (11/11).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 12:14:16 +05:30
saqib mirandClaude Sonnet 5 8400a8db84 feat(016-load-concurrency-testing): US5 — autocannon load-test tooling
tests/load/autocannon.config.ts is a thin shared wrapper over autocannon's
programmatic API producing this feature's own report shape (throughput,
latency p50/p90/p99, non-2xx, rate-limited count) — printed and written to
tests/load/reports/ (gitignored) for every run. No pass/fail threshold is
applied (FR-009): throughput/latency targets are an OPEN BUSINESS DECISION
per the roadmap's own convention, never invented.

Three scripts cover the named critical endpoint groups: ticket-creation
(pure DB path), admin-reporting (015-reporting-dashboards, pure DB path),
and ai-support-flow (005-ai-support's real Anthropic API calls — clearly
flagged as real, billed cost, run only at a small bounded amount rather than
an open-ended duration). All three were run once at a small scale against the
real dev server to confirm the tooling works end-to-end and cleans up fully
after itself (verified via direct DB checks, not just script exit codes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:50:12 +05:30
saqib mirandClaude Sonnet 5 cd8e408d7e test(016-load-concurrency-testing): US4 — prove ticket status optimistic concurrency
tests/concurrency/ticket-status-race.test.ts fires 20 genuinely concurrent
TicketsRepository.updateStatus calls from the same starting version against
real Postgres. Passes on the first run, confirming (rather than assuming)
003-ticketing's existing atomic version-checked updateMany already holds
under real concurrency — no implementation change needed (research.md §4).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:19:24 +05:30
saqib mirandClaude Sonnet 5 c7aac460b7 test(016-load-concurrency-testing): US3 — prove escalation idempotency holds
tests/concurrency/escalation-idempotency.test.ts fires the same escalation
trigger (escalationService.handleBreach) concurrently more than once for the
same ticket against real Postgres, asserting exactly one EscalationEvent and
one current Assignment result every time — verified across 10 repeated runs
(SC-003). The database-level unique-violation is visibly caught and absorbed
in the logs, confirming the fix (previous commit) actually engages under a
genuine race rather than being untested code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:17:31 +05:30
saqib mirandClaude Sonnet 5 93d6fe8b94 fix(016-load-concurrency-testing): US3 — finish escalation idempotency plumbing
Completes the escalation-event repository/service changes from the prior
commit: normalizes the exactOptionalPropertyTypes mismatch in the
findFirst fallback lookup, and updates every existing unit test
(sla-pause-resume, sla-breach-detection, sla-compliance-metric,
escalation-rule-match) to the new SlaRunRepository.updateWithVersion and
EscalationEventRepository.create({event, wasNewlyCreated}) signatures.
Full typecheck/lint/architecture-check clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 11:13:46 +05:30
saqib mir 794e16f349 create the specf document 2026-09-09 17:39:12 +05:30
saqib mirandClaude Sonnet 5 0e3543bb01 fix(016-load-concurrency-testing): US2 — version-guard SLA pause/resume/sweep
SlaRunRepository.updateWithVersion replaces the old unguarded update(),
mirroring TicketsRepository.updateStatus's exact atomic-updateMany pattern.
pause/resume/complete now retry (bounded) against fresh state on a version
conflict; the breach sweep skips a run that lost the race to a concurrent
pause/resume/complete rather than clobbering it, deferring to the next
scheduled pass. Verified against real Postgres: concurrent pause/resume/sweep
activity against the same run now always leaves it in one
internally-consistent state, across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:17:09 +05:30
saqib mirandClaude Sonnet 5 58d0a98134 fix(016-load-concurrency-testing): US1 — retry assignment creation on race conflict
AssignmentRepository.createAssignment now catches the
assignments_one_current_per_ticket unique-violation and retries the whole
supersede-then-create transaction (bounded, with jitter) instead of
propagating a raw P2002 to the caller. Verified against real Postgres: before
this fix, 20 genuinely concurrent assignment attempts on the same ticket
reliably threw an unhandled unique-constraint error; after it, exactly one
current assignment results every time across 10 repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:10:10 +05:30
saqib mirandClaude Sonnet 5 1e332143bd build(016-load-concurrency-testing): T001-T002 setup + concurrency-guard migration
Adds autocannon as a devDependency for the load-test tooling (T001), and the
shared schema migration T002 blocks: SLARun.version for optimistic
concurrency, plus two Postgres partial unique indexes
(assignments_one_current_per_ticket, escalation_events_ticket_rule_unique)
guarding against the assignment and escalation races research.md documents.
Applied directly to both the real dev DB and the throwaway test DB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 17:03:37 +05:30
saqib mirandClaude Sonnet 5 2fb5b7ac73 tasks(016-load-concurrency-testing): break down into 23 tasks across 5 stories
Foundational phase (T002) covers the one shared schema migration US1/US2/US3
depend on; US4 (proof-only, no schema change) and US5 (load-test tooling)
have no dependency on it and can proceed independently.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:57:09 +05:30
saqib mirandClaude Sonnet 5 015ef62b71 plan(016-load-concurrency-testing): design assignment/SLA/escalation race fixes
research.md nails down the exact mechanism for each real race the audit
found: a partial unique index for assignment double-assignment, a
Ticket-style version counter for SLA pause/resume/sweep, and a partial
unique index for escalation-rule idempotency — each traced to the specific
repository/service code that has the gap today. data-model.md and plan.md
carry the resulting schema and repository-contract changes; quickstart.md
defines the real-infra verification steps for each user story.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:55:32 +05:30
saqib mirandClaude Sonnet 5 9f52d51003 spec(016-load-concurrency-testing): specify concurrency-safety and load testing scope
Five user stories: assignment double-assignment race, SLA pause/resume race,
escalation idempotency, ticket optimistic-concurrency proof, and HTTP
load/throughput testing tooling. Scoped from a targeted audit of existing
concurrency guarantees rather than guesswork — see spec.md's Assumptions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:51:00 +05:30
saqib mirandClaude Sonnet 5 7106753ed3 fix(015-reporting-dashboards): count humanEscalated by Assignment existence, not status
ManagementRepository/ProductReportRepository.countEverEscalatedToHuman checked
a list of terminal statuses that ticket-state-machine.ts's own transition
table shows are reachable from BOTH the AI-resolved path and the
human-escalation path once they converge (RESOLUTION_PENDING_CUSTOMER,
RESOLVED, CLOSED, REOPENED). Every AI-resolved ticket was being double-counted
as human-escalated too — confirmed live against real seeded dev data
(humanEscalated: 34 out of totalCases: 34, an impossible 100%).

Fixed by keying off assignments: { some: {} } instead, since
orchestrationService.handleHumanEscalation is the only code path that ever
creates an Assignment row. Updated management-dashboard.test.ts's own
human-resolved fixture to create a real Assignment row, since it previously
relied on the now-fixed buggy status-based signal without one.

Found via manual verification against a real running dev server while
building supporthub-web's 002-reporting-dashboards-ui, not by any existing
automated test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 16:31:13 +05:30
saqib mirandClaude Sonnet 5 d65683641a feat(015-reporting-dashboards): four real reporting/analytics endpoints
Wires the pre-scaffolded, unused platform/reports module (ReportsService
.generateSummaryReport previously returned {}) into four real, admin-
gated dashboards matching docs/09-testing-observability-cicd.md's own
table:

- GET /admin/reports/management: total cases, AI-resolved, human-
  escalated, resolved/open, SLA compliance/breaches, escalation count,
  average response/resolution time.
- GET /admin/reports/product/:externalProductId: support volume,
  problem-category breakdown, recurring problems, AI-resolution/human-
  escalation rate, top error codes.
- GET /admin/reports/support: current per-agent workload, SLA at-risk/
  breached counts, escalation count, response/resolution performance.
- GET /admin/reports/ai: AI resolution/human-handoff rate, failed-
  troubleshooting-then-escalated rate, knowledge-match rate, confidence
  distribution (reusing 005-ai-support's own decideConfidenceBand),
  tool invocation success/failure.

Every rate/average is number|null -- null means no qualifying data in
range, never a computed NaN or a misleading 0. Adds one new durable
table, ErrorCodeLookup, since 014-full-observability's own equivalent
metric is a process-lifetime Prometheus counter unusable for a
historical "top errors" report.

Verified end-to-end against real Postgres/Redis: every figure checked
against hand-computed expected values, including a no-activity range
(all-zero counts, all-null rates) and cross-product isolation.

Also fixes a real regression the new ErrorCodeLookup FK caused in the
pre-existing known-issues.test.ts (its afterAll deleted ErrorCode rows
before the now-referencing lookup rows).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:59:38 +05:30
saqib mirandClaude Sonnet 5 814d9d7b17 docs(015-reporting-dashboards): task breakdown
28 tasks across a shared Foundational phase (schema, config, shared
rate/date-range helpers, module scaffolding) and 4 independently-testable
dashboard user stories.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:15:22 +05:30
saqib mirandClaude Sonnet 5 4a159725c2 docs(015-reporting-dashboards): plan, research, data model, contract, quickstart
Documents the exact Prisma query per dashboard figure, the one new
durable table this feature needs (ErrorCodeLookup — 014's own equivalent
metric is process-lifetime, unusable for a historical report), the
"no data -> null, never NaN" convention, and why the AI dashboard's
confidence distribution deliberately uses the system-default threshold
rather than resolving a per-diagnosis policy (AIDiagnosis has no
reliable FK back to which policy applied).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:14:29 +05:30
saqib mirandClaude Sonnet 5 c4a2faa6e3 docs(015-reporting-dashboards): feature spec and quality checklist
Phase 11's third sub-area (reporting/analytics dashboards), per explicit
user direction. Backend-first scope (four read-only aggregation
endpoints wiring up the pre-scaffolded platform/reports module),
following the same backend-before-frontend pattern already established
for 010/011/014 this session — a supporthub-web dashboard UI is a
separate, not-yet-started follow-on.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 11:10:00 +05:30
saqib mirandClaude Sonnet 5 ea50e3596a test(014-full-observability): fix cross-file contamination + polish
business-metrics.test.ts's "human resolution" case drove a ticket
through a real HUMAN_ESCALATION transition via ticketsService.updateStatus,
which triggers the real orchestration subscriber's default ROUND_ROBIN
auto-assignment against every agent in the shared throwaway database —
reproduced deterministically landing on agent-ticket-queue.test.ts's own
dedicated agent. Fixed by driving the intermediate transitions directly
through ticketsRepository.updateStatus (no domain-event publish),
reserving the real, event-publishing call for only the final RESOLVED
transition the metric subscriber needs to observe.

Also documents (checklist Notes), without fixing, a separate pre-existing
issue confirmed unrelated to this feature via git checkout to the clean
013-auth-hardening tip: nearly every integration test file's product ID
collapses to the same 4-letter ticket-code prefix ("TEST"), so enough
concurrent TEST_*-prefixed files can exceed the fixed retry ceiling on
ticket-code generation and surface as a real 500 — a 003-ticketing
concern, out of scope here.

Marks all 30 tasks.md items complete.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 16:27:56 +05:30
saqib mirandClaude Sonnet 5 de5915a8c1 feat(014-full-observability): remaining business metrics (first response, resolution, SLA, errors, tools)
Completes User Story 4's eleven named metrics: first-response-time
(messages.service.ts's post(), guarded against double-counting a
ticket's second agent message), SLA compliance (sla.service.ts's
complete()/runBreachDetectionSweep(), with a guard so a run already
breached by the sweep is never also counted "met" when it later
resolves), most-common-errors (error-codes.service.ts, counted only
once a code is confirmed real), and tool-failure-rate/knowledge-
effectiveness (tools.service.ts's single executeTool call site).

Verified end-to-end against real Postgres/Redis by scraping the real
/metrics endpoint before and after driving each metric's actual
underlying event through the real service layer — including a genuine
tool-execution failure (a nonexistent ticket ID) rather than a
simulated one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 15:57:47 +05:30
saqib mirandClaude Sonnet 5 f75589d9ce feat(014-full-observability): real distributed tracing + first 5 business metrics
User Story 3: initializes a real OpenTelemetry TracerProvider (previously
inert — getTracer() returned a no-op tracer with nothing ever exported).
Adds ticket.create, ai.escalation, and orchestration.assignment spans
covering both FR-006 cross-module paths, verified via a real, in-memory
test exporter that confirms actual trace/parent-span nesting, not mocked.

Also registers an AsyncLocalStorageContextManager
(@opentelemetry/context-async-hooks) — without one, OTel's context API is
a no-op that doesn't propagate across the await boundaries this feature's
own event-bus subscribers rely on for span nesting; caught by the first
version of the tracing integration test actually failing on real
parent/child assertions, not assumed.

Graceful degradation (FR-007) verified against a real, deliberately
unreachable OTLP endpoint: the SDK's own background export path (what
production actually exercises) never produces an unhandled rejection.

Starts on the 11 named business-health metrics: AI session
resolved/escalated outcomes (session.repository.ts, the single choke
point every branch in session.service.ts funnels through), human-vs-AI
resolution + resolution-time (a new TICKET_UPDATED/RESOLVED subscriber),
escalation rate (a new subscriber on ESCALATION_TRIGGERED, published
unconditionally since 008 but never previously consumed), and recurring
problems (tickets.service.ts's existing problem-creation call site).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:32:14 +05:30
saqib mirandClaude Sonnet 5 acd3843aaf feat(014-full-observability): per-request access log + live request-duration metric
User Stories 1-2: every completed request (including 404s and early
replies from other hooks) now emits exactly one structured access-log
line, and every log line produced during that request's handling shares
its requestId/correlationId via a new AsyncLocalStorage-backed Pino mixin
— with zero changes to any existing log call site. The previously-dead
supporthub_http_request_duration_seconds histogram now actually receives
observations, so error rate and latency per route are computable from
/metrics alone.

Also bumps @opentelemetry/sdk-trace-base 1.x -> 2.x to align with the two
new tracing dependencies added in this same branch (exporter-trace-otlp-http,
resources) onto one consistent major version — npm had otherwise installed
two incompatible OTel core/resources majors side by side, which also
happened to resolve a moderate DoS advisory in @opentelemetry/core <2.8.0.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 11:12:14 +05:30
saqib mirandClaude Sonnet 5 0135e4ca05 docs(014-full-observability): task breakdown
30 tasks across 4 independently-testable user stories plus a shared
foundational phase (ALS request-context store + new OTel dependencies).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:52:26 +05:30
saqib mirandClaude Sonnet 5 5a0fe9f847 docs(014-full-observability): plan, research, data model, contract, quickstart
Documents the exact hook point chosen for each of the 3 dead observability
primitives (access log, request-duration histogram, tracer provider) and
the 11 named business-health metrics, verified against the real current
code rather than assumed — including a pre-existing SLA-run status data
quality gap surfaced along the way (documented, not fixed here).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-08 10:51:22 +05:30
saqib mirandClaude Sonnet 5 09ba56d3af docs(014-full-observability): feature spec and quality checklist
Phase 11's second sub-area (full observability), per explicit user
direction. Scopes wiring the already-scaffolded logging/metrics/tracing
into something actually functional, explicitly bounded away from the
separate, not-yet-started reporting/analytics dashboards sub-area.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:31:38 +05:30
saqib mirandClaude Sonnet 5 79bc2ef25b feat(013-auth-hardening): password reset, password strength policy, login rate-limiting
Closes the two gaps 010-identity-auth explicitly deferred (password reset,
login rate-limiting), plus a shared password-strength validator both the
reset-consume endpoint and admin account creation now depend on.

- Password reset: single-use, paired-Redis-key tokens (never in Postgres),
  identical response regardless of account existence, stubbed delivery via
  a structured log line (no email infrastructure exists yet).
- Password strength: one validatePasswordStrength() call site, wired into
  both POST /admin/users and the reset-consume flow.
- Login rate-limiting: checkRateLimit keyed by submitted email, checked
  before any credential verification.

Also fixes tests/helpers/auth.ts's shared loginAs() helper, which reused
two fixed accounts across the whole integration suite via upsert — now
rate-limited per email, that collided across ~30 files sharing one budget.
Each call now gets a unique email; no call sites needed to change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 21:22:49 +05:30
saqib mirandClaude Sonnet 5 3bdccc901f test(011-agent-ticket-queue): harden afterAll against wildcard SLA policy contamination
Same class of cross-file test-isolation gap already fixed in
orchestration-flow.test.ts and sla-escalation-flow.test.ts (010's own
regression work): a wildcard (non-product-scoped) SLA policy from
another suite can match this file's own tickets too, leaving a real
sla_run row that RESTRICTs the ticket delete. Also cleaned up several
orphaned wildcard SLA policies that had accumulated in the shared
throwaway test database from earlier runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:33:52 +05:30
saqib mirandClaude Sonnet 5 65175a85b5 docs(013-auth-hardening): task breakdown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:27:27 +05:30
saqib mirandClaude Sonnet 5 52f1fa3db0 docs(013-auth-hardening): plan, research, data model, contract, quickstart
Reset tokens live only in Redis as a paired key shape (mirrors 010's own
revocation-denylist pattern) - never in Postgres, never storing the raw
token. Password-strength policy is one shared validator called from both
the new reset-consume endpoint and 010's existing POST /admin/users.
Login rate-limiting reuses the existing checkRateLimit helper from
002's own inbound trust boundary, keyed by submitted email, checked
before any credential verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:26:49 +05:30
saqib mirandClaude Sonnet 5 b016e77b70 docs(013-auth-hardening): spec for password reset, password policy, login rate-limiting
Phase 11's "security hardening pass" (docs/10-implementation-roadmap.md),
first slice, per explicit user direction. Closes the two concrete gaps
010-identity-auth's own Assumptions named as out of its scope. MFA is
intentionally excluded as its own larger follow-up feature. Email
delivery for password-reset is stubbed (server-side log) per explicit
user decision, since this codebase has no email infrastructure at all
today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:23:49 +05:30
saqib mirandClaude Sonnet 5 700daf4104 feat(012-admin-list-views): GET /admin/escalation-policies now includes each policy's rules
Follow-up to 012-admin-list-views, discovered while building
supporthub-web's own escalation admin screen (001-agent-admin-ui User
Story 5): the list endpoint returned bare policies with no way to read
back which rules (trigger type, target node) already existed under
each one.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:42:25 +05:30
saqib mirandClaude Sonnet 5 249e7cd0ce feat(012-admin-list-views): GET /admin/business-calendars/:id now includes holidays
Follow-up to 012-admin-list-views, discovered while building
supporthub-web's own SLA/calendar admin screen (001-agent-admin-ui User
Story 4): holidays could only be added or removed, never read back -
GET /admin/business-calendars/:id returned the bare calendar with no
way to display what holidays were already on file. The repository
already had findByIdWithHolidays; it just wasn't wired to this route.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:40:00 +05:30
saqib mirandClaude Sonnet 5 2034966d6d docs(012-admin-list-views): note the knowledge-governance follow-up
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:35:47 +05:30
saqib mirandClaude Sonnet 5 7948182988 feat(012-admin-list-views): add GET /admin/products/:id/knowledge for governance
Follow-up to 012-admin-list-views, discovered while building supporthub-
web's own knowledge-governance screen (001-agent-admin-ui User Story 7):
GET /knowledge/retrieve only ever returns published entries (its own
AI-consumption purpose), so a governance screen that needs to see and
publish a draft entry had no endpoint to list it. Adds a small
admin-list-views-style read query scoped to the knowledge module itself.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:35:25 +05:30
saqib mirandClaude Sonnet 5 3bd068b031 feat(012-admin-list-views): SLA-run, escalation-event, and product-catalog list endpoints
Adds GET /admin/sla-runs (filterable by status), GET /admin/escalation-
events (capped, most-recent-first), and GET /admin/products (with
integration status joined in, never the full ProductIntegration row).
None of these existed as a single query before - only per-ticket or
per-integration-id lookups did.

Discovered while planning supporthub-web's 001-agent-admin-ui User
Stories 6-7 (SLA/escalation monitoring, product catalog), the same way
011-agent-ticket-queue was discovered for User Story 1.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:31:20 +05:30
saqib mirandClaude Sonnet 5 43158ff0c4 docs(012-admin-list-views): task breakdown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:14:52 +05:30
saqib mirandClaude Sonnet 5 2b00b6d6a1 docs(012-admin-list-views): plan, research, data model, contract, quickstart
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:14:25 +05:30
saqib mirandClaude Sonnet 5 49db40d7c1 docs(012-admin-list-views): spec for SLA-run, escalation-event, and product-catalog list endpoints
Discovered while planning supporthub-web's 001-agent-admin-ui User
Stories 6-7: no endpoint lists SLA runs or escalation events across
multiple tickets (only per-ticket), and no endpoint returns the product
catalog with integration status joined in.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 16:12:21 +05:30
saqib mirandClaude Sonnet 5 fb9606b6aa feat(011-agent-ticket-queue): link agents to accounts, list assigned tickets
Extends PATCH /admin/agents/:agentId with an optional userId to finally
wire Agent.userId (added in 010-identity-auth as schema-only, never
consumed by any workflow), with proactive role/duplicate-link checks
mirroring UsersService.create's own pre-check style.

Adds GET /agents/me/tickets and GET /admin/agents/:agentId/tickets,
sharing one TicketsService.listAssignedTo method, returning a dashboard-
ready summary (product, customer, priority, severity, status, SLA state)
of every ticket currently assigned to an agent — no such query existed
anywhere in the ticketing or orchestration modules before this. Backed by
a new Assignment @@index([agentId, isCurrent]).

Discovered while starting supporthub-web's 001-agent-admin-ui: its agent-
dashboard user story had no backend data source without this.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:19:13 +05:30
saqib mirandClaude Sonnet 5 d574af087a docs(011-agent-ticket-queue): task breakdown
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 13:00:35 +05:30
saqib mirandClaude Sonnet 5 23fadebb5c docs(011-agent-ticket-queue): plan, research, data model, contract, quickstart
Extends the existing PATCH /admin/agents/:agentId with an optional userId
to finish wiring 010's Agent.userId link, and adds GET /agents/me/tickets
+ GET /admin/agents/:agentId/tickets sharing one ticketing/tickets service
method, backed by a new Assignment @@index([agentId, isCurrent]).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:58:25 +05:30
saqib mirandClaude Sonnet 5 d58bc98c7f docs(011-agent-ticket-queue): spec for agent-user linking and assigned-ticket listing
Discovered while starting supporthub-web's 001-agent-admin-ui planning:
its agent-dashboard user story needs to list tickets currently assigned
to an agent, and no such query exists anywhere in the ticketing or
orchestration modules. Also finishes wiring Agent.userId (added in
010-identity-auth as schema-only, never consumed by any workflow) so a
logged-in session can resolve to its own agent roster row at all.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:55:41 +05:30
saqib mirandClaude Sonnet 5 40687f68fa feat(010-identity-auth): real staff login, session verification, and role gating
Replaces the no-op fastify.authenticate stub and the never-implemented
identity/auth login with real bcrypt password verification, JWT session
issuance/verification (reusing the existing JWT_SECRET), and a Redis-backed
revocation denylist for logout. Adds requireRole('ADMIN') to admin-only
configuration writes across 002-009 that previously relied on a decorator
that never actually checked anything. Adds self-identity (GET /auth/me,
re-validated against live account state) and admin-provisioned accounts
(POST /admin/users).

Making the auth check genuinely reject invalid/missing tokens exposed that
~18 pre-existing integration test files called already-gated routes with no
Authorization header (safe against the old no-op stub, broken against a real
one) — fixed via a shared tests/helpers/auth.ts (loginAs/authHeader) and a
file-by-file pass, plus two related SLA-run cleanup races exposed once admin
setup calls in those files' own beforeAll blocks started actually succeeding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 12:45:37 +05:30
saqib mirandClaude Sonnet 5 8327fafac2 tasks: task breakdown for identity and authentication feature (010)
36 tasks across 8 phases (5 user stories + setup/foundational/polish).
US1 (real login) and US2 (real route/role gating) are the P1 MVP; the
one task that touches code outside identity/* (T020, adding
requireRole('ADMIN') across 002-009's existing admin routes) is called
out explicitly to run each touched module's own test suite immediately
after, not only in the final regression pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:15:45 +05:30
saqib mirandClaude Sonnet 5 3b4c220a45 plan: design for identity and authentication feature (010)
Phase 0 research resolves the library choices (jsonwebtoken + bcryptjs,
chosen partly to avoid native-build friction on Windows dev
environments), the Redis-backed revocation-denylist shape (reusing
002's own jti-replay-protection pattern exactly), a 4-hour token
lifetime, and why fastify.authenticate populating the already-shared
reqContext.actorId/actorType retroactively makes every audit trail
since 007 accurate for real agent/admin actions instead of always
'unknown'.

Also surfaces and scopes a real gap found along the way: User (login
identity) and Agent (routing/skills profile) have never been linked.
Adds Agent.userId as a nullable FK now (cheap, additive) without
building the actual linking workflow, which belongs in 006's own
identity/agents admin screens as a later, separate piece of work.

Phase 1 adds data-model.md, the login/self-identity/account-creation/
logout contract, and five quickstart scenarios including a specific
requirement to re-verify at least one already-shipped admin route per
module (002-009), not just this feature's own new endpoints.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:14:42 +05:30
saqib mirandClaude Sonnet 5 a49389dc2c docs: spec for identity and authentication (010)
Not on the original roadmap -- surfaced as a genuine blocking gap while
planning supporthub-web's own agent/admin UI feature: fastify.authenticate
has been a complete no-op stub since 002, and identity/auth's login
endpoint has never taken a password. User/UserRole (two seeded-but-
passwordless demo accounts) and the AuthUser/JwtPayload types were all
already scaffolded and clearly intended for exactly this -- this finishes
that original wiring rather than inventing a new design.

Scope: real login (password hash + JWT), fastify.authenticate actually
rejecting invalid sessions, role-based route gating, a self-identity
endpoint, admin-created accounts, and logout. Password reset, MFA, and
login rate-limiting are explicitly deferred to Phase 11's own security
hardening pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 11:09:46 +05:30
saqibmir 051ee88974 Merge pull request 'development' (#15) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/15
2026-09-03 11:21:04 +00:00
saqibmir 177488caf8 Merge pull request 'fix' (#14) from 009-problem-resolution into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/14
2026-09-03 11:20:27 +00:00
saqib mir 4e0c90e2cf fix 2026-09-03 16:48:27 +05:30
MdIrshad1234 5607fdfcd5 Merge branch 'main' of https://gitea.maskantech.in/gitea_admin/support_backend 2026-09-03 16:46:02 +05:30
MdIrshad1234 ca14d45ff1 marge the solve 2026-09-03 16:45:45 +05:30
saqibmir a5650b1089 Merge pull request 'development' (#13) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/13
2026-09-03 11:12:34 +00:00
saqibmir 7e92a1679b Merge pull request '009-problem-resolution' (#12) from 009-problem-resolution into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/12
2026-09-03 11:11:24 +00:00
saqib mir d9bd970910 have add tthe port number 2026-09-03 16:40:19 +05:30
saqib mirandClaude Sonnet 5 16daf8d32d feat: implement problem resolution (009)
Populates the five real problem-management stubs (investigation,
root-causes, solutions, verification, resolutions -- problems is
confirmed dead/unwired scaffold and stays untouched) with doc04's
sequential workflow engine:

- investigation: version-row-per-attempt (never overwritten), with a
  customer-safe read path that always strips internalNotes.
- root-causes/solutions/verification: a strict existence chain
  (investigation -> root cause -> solution -> approval -> implementation
  -> verification), each step resolve-or-409 on its own precondition,
  matching doc06's schema field-for-field with no invented columns.
- resolutions: gated on a successfully verified solution (no stored
  solutionId FK, per doc06 -- resolved via a join at write time), moving
  the ticket to RESOLUTION_PENDING_CUSTOMER; explicit customer
  confirmation and a durable auto-close sweep (the previously-unregistered
  CLEANUP queue stub, mirroring 008's breach-detection job) both resolve
  it from there.
- reopen (ticketing/tickets): two real, separately-audited transitions
  (RESOLVED|CLOSED -> REOPENED -> IN_PROGRESS), touching no prior
  problem-resolution record and no SLARun -- closes the loop 008's own
  spec.md left open.

Verification-failure escalation reuses 003/007's existing
HUMAN_ESCALATION transition directly rather than adding an eleventh
trigger type to 008's already-shipped escalation rules.

Customer-facing confirm-resolution/reopen needed a body-shape variant of
002's inbound trust boundary that didn't previously exist:
fastify.authenticateProductIntegration hard-required a full
ticket-creation-shaped body. Extracted the shared token/scope/replay
verification into verifyIntegrationIdentity and added a narrower
authenticateProductIntegrationIdentity decorator + identityOnlyRequestSchema
on top of it -- purely additive, ticket creation's own behavior is
unchanged.

Also fixes a real test-data-hygiene bug surfaced by running this
feature's suite alongside 008's: a wildcard-scoped HierarchyNode and an
intentionally-global SLAPolicy in 008's own test fixtures were silently
affecting other test files' tickets sharing the same live Postgres.

Verified against throwaway Docker Postgres/Redis: typecheck, lint,
architecture-check all clean; full regression (tests/unit +
tests/integration together, 172 tests) passes except the 2 pre-existing
MinIO-dependent attachment failures, unrelated to this feature.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 15:09:29 +05:30
saqib mirandClaude Sonnet 5 aaa51ef475 tasks: task breakdown for problem resolution feature (009)
49 tasks across 9 phases (6 user stories + setup/foundational/polish).
Unlike 007/008, this feature's user stories are genuinely sequential
(doc04's own investigation -> root cause -> solution -> verification ->
resolution -> reopen chain), so each story's dependency on the last is
real, not just priority-driven ordering -- called out explicitly since
User Story 4 (P2, verification) is a structural prerequisite of User
Story 5 (P1, resolution) despite the lower priority label.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:39:11 +05:30
saqib mirandClaude Sonnet 5 9ce34d8ca4 plan: design for problem resolution feature (009)
Phase 0 research resolves module placement (problem-management/problems
confirmed dead/unwired, left untouched), Investigation's version-row-per-
attempt shape, the strict investigation->root-cause->solution->
implementation->verification existence chain, why Resolution has no
solutionId FK (matches doc06 exactly), why verification-failure
escalation reuses 003/007's plain HUMAN_ESCALATION transition instead of
adding an eleventh trigger type to 008's already-shipped escalation
rules, the customer-facing route design (reusing 002's inbound trust
boundary rather than fastify.authenticate), and the auto-close sweep
design (the already-defined-but-unused CLEANUP queue, mirroring 008's
breach-detection job).

Phase 1 adds data-model.md, the admin/customer-facing contract, and six
quickstart scenarios covering the full sequential workflow through
customer confirmation, auto-close, and reopen.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:37:42 +05:30
saqib mirandClaude Sonnet 5 14c6793460 docs: spec for problem resolution feature (009)
Investigation -> Root Cause -> Solution -> Implementation ->
Verification -> Resolution, plus customer confirmation and reopen,
matching doc04 SS3-9's workflow narrative and doc06's "Domain: Problem
Resolution" schema exactly. Explicitly closes a loop 008's own spec left
open (reopen never restarts an SLA run); verification-failure escalation
reuses 003/007's existing HUMAN_ESCALATION transition rather than adding
an eleventh escalation-rule trigger type to 008's system.

Also documents a real scope-boundary finding: src/modules/problem-management/problems
is a dead, unwired duplicate scaffold for Problem (the real one has lived
in ticketing/tickets since 003) and is not touched by this feature -- only
the five investigation/root-causes/solutions/resolutions/verification
stub directories are.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 14:33:55 +05:30
MdIrshad1234 d902f6b26d slove the merge 2026-09-03 14:27:16 +05:30
saqibmir 6a7c6a493e Merge pull request 'development' (#11) from development into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/11
2026-09-03 08:52:59 +00:00
saqibmir 6beef9584b Merge pull request '008-sla-escalation' (#10) from 008-sla-escalation into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/10
2026-09-03 08:50:56 +00:00
saqib mir 7e3d2ae29f add the new feaures 2026-09-03 14:19:21 +05:30
saqib mirandClaude Sonnet 5 9357f03e1d feat: implement SLA and escalation (008)
Populates platform/business-calendars, orchestration/sla, and
orchestration/escalation (all thin stubs until now) with the real engine:

- business-calendars: a luxon-based day-by-day calendar walk
  (addBusinessMinutes/isWithinWorkingHours) excluding non-working hours,
  weekends, and holidays — replacing the naive createdAt+hours stub FR-004
  explicitly forbids.
- sla: most-specific SLAPolicy resolution (product/category/problemType/
  priority, wildcard-or-exact-match, specificity-count + updatedAt
  tiebreak), SLARun creation on the first real publish of the
  long-unused TICKET_ASSIGNED domain event, durable pause/resume via an
  absolute-timestamp shift (no in-memory state, verified across a real
  buildApp() restart), and a repeatable BullMQ breach-detection sweep
  (src/jobs/sla, itself a previously-unregistered stub) that is directly
  callable for tests, not only reachable through a running worker.
- escalation: EscalationPolicy/Rule CRUD (all 10 doc05 trigger types
  storable, only resolution_breach/first_response_breach evaluated),
  breach-triggered and manual escalation both funnel through one EscalationEvent
  + scoped re-assignment path. AssignmentEngine (007) gains
  assignToSpecificNode — a new, explicitly node-scoped entry point,
  since escalation must never let 007's general resolution re-derive a
  different node than the one a rule or a caller targeted.

Two small pre-existing scaffold gaps were closed along the way:
CategoriesRepository had no findById, and TICKET_ASSIGNED/SLA_BREACHED/
ESCALATION_TRIGGERED were defined since earlier phases but never
published by any code.

Verified against throwaway Docker Postgres/Redis (typecheck, lint,
architecture-check all clean; 148/150 relevant tests pass — the 2
failures are pre-existing, MinIO-dependent, and unrelated to this
feature).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 13:02:05 +05:30
saqib mirandClaude Sonnet 5 bb31e9d641 tasks: task breakdown for SLA and escalation feature (008)
48 tasks across 9 phases (6 user stories + setup/foundational/polish),
sequenced US1 (policy definition) -> US2 (calendar-aware run creation) ->
US3 (durable pause/resume, P1-complete MVP) -> US4 (breach detection) ->
US5 (breach-triggered escalation) -> US6 (manual escalation), each
dependent on the last since every story builds on the previous one's
mechanism rather than being independently orderable.

Also folds in two design refinements found while cross-checking the
existing scaffold against research.md's plan: TICKET_ASSIGNED (defined in
domain-events.ts since 007, never published) becomes the real wiring point
for SLA-run creation, and src/jobs/sla//src/jobs/escalation/ turn out to
already exist as their own stub scaffolding, reused rather than
duplicated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:27:40 +05:30
saqib mirandClaude Sonnet 5 199bd4eb4e plan: design for SLA and escalation feature (008)
Phase 0 research resolves the business-calendar working-hours algorithm
(day-by-day walk via luxon, the first date/timezone dependency in this
codebase), the workingHours JSON shape, most-specific SLA-policy match
(reusing 005/006's resolution pattern), durable pause/resume (absolute
due-date shift, no in-memory state), and a repeatable-job breach-detection
design over per-run delayed jobs. Phase 1 adds data-model.md (one additive
refinement beyond doc06: SLARun.firstResponseBreachedAt), the admin/read
contract, and six quickstart scenarios including a genuine process-restart
boundary test for Constitution Principle VII.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:23:35 +05:30
saqib mirandClaude Sonnet 5 1ad9007e79 docs: spec for SLA and escalation feature (008)
Phase 8 of the roadmap: SLA policy engine (most-specific-match, calendar-
aware due dates), durable pause/resume and breach detection (never an
in-memory timer), and a rule-driven escalation engine that re-assigns via
007's engine scoped to a specific target node. Bounded to the two
SLA-derived trigger types this feature can compute a real signal for;
the other eight doc 05 §6 trigger types remain valid rule configuration
without a wired event source yet. First feature to give 006's
HierarchyNode.slaPolicyId/escalationPolicyId fields a real target.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:16:05 +05:30
saqibmir 71520e2423 Merge pull request '007-orchestration-assignment' (#9) from 007-orchestration-assignment into development
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/9
2026-09-03 06:41:48 +00:00
saqibmir 10f59a7d5b Merge pull request '007-orchestration-assignment' (#8) from 007-orchestration-assignment into main
Reviewed-on: https://gitea.maskantech.in/gitea_admin/support_backend/pulls/8
2026-09-03 06:33:07 +00:00
saqib mir 3ecca06307 merged solved 2026-09-03 12:01:26 +05:30
saqib mirandClaude Sonnet 5 77928c4878 feat: implement orchestration and assignment (007) — routing, strategies, history
Phase 7 of the roadmap. On a ticket's automatic transition to
HUMAN_ESCALATION, routing resolves 006's hierarchy nodes and
capability-eligibility lookup directly (never a second matching
algorithm), and a pluggable strategy (ROUND_ROBIN, concurrency-safe via
atomic Redis INCR; LEAST_LOADED; SKILL_BASED) selects exactly one
eligible agent, persisted as a version-row-per-period Assignment plus an
append-only AssignmentHistory event log. MANUAL/DIRECT are never
auto-selected — only an explicit admin-supplied agentId reaches them.
On success the ticket moves HUMAN_ESCALATION -> IN_PROGRESS through
003's existing state machine. A ticket's "required skill" comes from
its most recent AI diagnosis's problemType (005) when one exists,
unioned with any matching hierarchy node's skills (006); when neither
exists, there's no skill constraint (every active agent eligible),
never zero.

Found and fixed two real, latent bugs in the shared event-bus
infrastructure while building this feature's own tests: (1)
EventBus.publish was built on EventEmitter.emit(), which never awaits
async listeners, so a caller had no guarantee any subscriber (005's
AI-session-ending hook, now also this feature's orchestration hook) had
actually finished — rewritten to track subscribers directly and await
them via Promise.all, same per-handler error isolation as before. (2)
registerDomainEventHandlers() was only called from server.ts's
production startup path, never from buildApp() — meaning every
integration test in this codebase had zero domain-event subscribers
registered at all. Now called (idempotently) from buildApp() itself,
since domain-event wiring is synchronous application behavior, not a
background-worker concern like the queue.

Adds 8 unit tests (each strategy's pure selection/tie-break logic), a
dedicated round-robin concurrency test verifying no two concurrent
selections collide under real parallel load, and 2 integration test
files covering all five user stories. Full regression (every
pre-existing 002-006 integration test plus every new 007 test) run
together against real Postgres/Redis/MinIO: 124 passed, 9 skipped
(005's AI-key-gated tests, unrelated), 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:58:17 +05:30
saqib mirandClaude Sonnet 5 d3d57b9954 docs: correct 007-orchestration-assignment design on required-skill sourcing
Neither Ticket nor Problem carries a skill/problem-type field — the
only real signal is AIDiagnosis.problemType (005), and only for
tickets that went through AI support first. Resolves this before
implementation: use that diagnosis when available, fall back to no
skill constraint (not zero eligible agents) otherwise, matching FR-004's
explicit "rather than failing" and 006's own empty-scope-matches-
everything convention.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:32:49 +05:30
saqib mirandClaude Sonnet 5 67eee83ff0 docs: task breakdown for orchestration and assignment feature
34 tasks across setup, foundational schema work, and five user stories
(automatic resolution, pluggable strategies with a dedicated concurrency
test, durable history, manual assignment, and re-escalation
verification). Notes the T021/T017 sequencing exception where an
implementation dependency crosses story-priority order.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:31:30 +05:30
saqib mirandClaude Sonnet 5 846b9e8dca docs: plan and design artifacts for orchestration and assignment feature
Maps the feature onto the three existing orchestration/{routing,
assignments,orchestration} scaffold stubs. Key decisions: Assignment
refined as a version-row-per-period model (paired with a separate
append-only AssignmentHistory event log), round-robin concurrency
safety via atomic Redis INCR (reusing existing infra, not a new one),
LEAST_LOADED/SKILL_BASED tie-breaks falling back to that same cursor,
currentLoad read but never mutated by this feature, and the escalation
trigger reusing 005's existing domain-event bus rather than a new
notification path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:30:23 +05:30
saqib mirandClaude Sonnet 5 e437711c2a docs: spec for orchestration and assignment feature (007)
Phase 7 of the roadmap: the orchestration engine (resolving 006's
hierarchy nodes and capability-eligibility lookup against an escalated
ticket), pluggable assignment strategies (ROUND_ROBIN/LEAST_LOADED/
SKILL_BASED/MANUAL/DIRECT) with concurrency-safe round robin, and
durable assignment history. Explicitly excludes SLA/escalation-policy
execution (Phase 8). Triggered via 005's existing domain-event
publishing rather than a new notification path.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 11:27:19 +05:30
saqib mirandClaude Sonnet 5 a0c1a9aa33 feat: implement support organization (006) — teams, agents, hierarchy, capability lookup
Phase 6 of the roadmap. Populates the identity/teams and
orchestration/hierarchy module directories (previously empty or
near-empty scaffold stubs) and replaces identity/agents' pre-existing
placeholder, which queried a generic User/UserRole model unrelated to
this system's real architecture (nothing since 002-saas-integration's
CustomerReference has used it).

Teams and agents: active/inactive CRUD, never hard deletion; team
deactivation never cascades to its agents. Skills: compound-unique
upsert on (agentId, skillTag), never duplicates. Availability: a single
current record per agent, deliberately last-write-wins rather than
optimistic-locked — operational telemetry, not a durable business
record. The dynamic hierarchy: a nestable, orderable HierarchyNode tree
with every field (scope, assignment strategy reference, entry/exit
conditions) stored as opaque admin-set data; cycle detection runs only
on reparenting edits (a new node can't form a cycle); every
create/edit/activate/deactivate is audited via AuditLog, reusing
002's existing writer pattern. A capability-eligibility read path
composes hierarchy scope with caller-supplied skills for the future
orchestration/assignment phase (Phase 7) to call, deliberately
excluding availability per doc 05's own capability-before-availability
ordering.

Found and fixed a real bug before it reached tests: the initial
availability upsert reset currentLoad to 0 on every update, not just
creation.

Adds 11 unit tests (cycle detection, capability matching) and 4
integration test files covering all four user stories. Full regression
(every pre-existing 002-005 integration test plus all new ones) run
against real Postgres/Redis/MinIO: 107 passed, 9 skipped (005's
AI-key-gated tests, unrelated), 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 18:12:26 +05:30
saqib mirandClaude Sonnet 5 e703f826de docs: task breakdown for support organization feature
33 tasks across setup, foundational schema work, and four user stories
(teams/agents, skills/availability, the dynamic hierarchy tree, and a
capability-eligibility read path for the future orchestration phase).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:53:30 +05:30
saqib mirandClaude Sonnet 5 c18e203ad1 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>
2026-09-02 17:52:33 +05:30
saqib mirandClaude Sonnet 5 e1983438b9 docs: spec for support organization feature (006)
Phase 6 of the roadmap: Team/Agent/AgentSkill/AgentAvailability models,
dynamic HierarchyNode configuration, and a capability-eligibility read
path for the future orchestration phase to call. Supersedes the
pre-existing identity/agents scaffold stub (which queried a generic
User/UserRole model unrelated to this system's real architecture);
leaves identity/customers and identity/auth untouched as out of scope.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:49:50 +05:30
saqib mirandClaude Sonnet 5 82d02bcdcd feat: implement AI support agent (005) — diagnosis, tools, runbooks, verification
Real Anthropic Claude integration per explicit product decision: a
ticket's AI session diagnoses the problem via a structured-output call,
applies a DB-configurable confidence-band policy (FR-005), and on
"proceed" reasons and acts through a small permission/risk-gated tool
system (FR-011/FR-012), optionally walking a matching runbook step by
step with the application — never the model — owning the step index
(FR-015/FR-016). Resolution requires real tool evidence, never customer
claims alone (FR-018) — verifyProductResolution is a documented
fail-closed placeholder mirroring the existing malware-scanner precedent,
since no real per-product operational signal exists yet.

AISupportSession.status mirrors onto Ticket.status through 003-ticketing's
existing AI_ANALYZING/AI_TROUBLESHOOTING/AI_VERIFYING/AI_RESOLVED/
HUMAN_ESCALATION state machine, discovered during planning to have been
built anticipating this exact feature. Two circular module dependencies
(escalation<->sessions, tools<->sessions) were designed around rather than
found as bugs: escalation is a pure summary formatter with no state
dependencies of its own, and tools stays a clean leaf module with zero
dependency on ai-support/sessions. Ticket creation enqueues the first
diagnosis turn via the existing queue infrastructure (off the hot path of
the inbound SaaS integration endpoint); a human actor changing ticket
status ends the AI session via the event-bus scaffold that existed in
this codebase but had never been wired to anything.

A real Prisma limitation was found and fixed before it reached tests:
compound-unique upsert rejects null for a nullable key column, so
AIConfidencePolicy uses find-then-update/create instead, same fix class
004 already used for the same underlying limitation.

Adds 9 unit tests (confidence-band, tool-policy-gate, runbook-step-
advance) and 6 integration test files, including the two constitution-
required standing E2E scenarios. AI-independent tests were run against
real Postgres/Redis/MinIO (88 passed, 0 failed across the full suite,
including every pre-existing 002/003/004 test). The AI-dependent tests
compile and skip cleanly via describe.skipIf but were not run against a
live model — no ANTHROPIC_API_KEY was available in this session; a real
key must be supplied before this feature can actually run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 17:44:52 +05:30
saqib mirandClaude Sonnet 5 49eaa4bc58 docs: task breakdown for AI support agent feature
53 tasks across setup, foundational schema/env/LLM-client work, and five
user stories (diagnosis+confidence policy, clarification loop, gated tool
system, runbook engine, evidence-based verification), plus the two
constitution-required standing E2E scenarios in Polish.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:56:42 +05:30
saqib mirandClaude Sonnet 5 eceb00632d docs: correct 005-ai-support design to reuse the existing ticket state machine
003-ticketing's ticket-state-machine.ts already defines AI_ANALYZING/
AI_TROUBLESHOOTING/AI_VERIFYING/AI_RESOLVED/HUMAN_ESCALATION ticket
statuses, clearly authored anticipating this feature. Corrects the plan
before implementation: AISupportSession.status now drives Ticket.status
through the existing ticketsService.updateStatus (reusing its optimistic
concurrency), instead of an isolated status field the rest of the system
never sees. Also clarifies that knowledge retrieval is an in-process
service call through knowledge's index.ts, not an HTTP loopback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:55:01 +05:30
saqib mirandClaude Sonnet 5 72dddcdf74 docs: plan and design artifacts for AI support agent feature
Two-call reasoning design (structured-output diagnosis, then a separate
knowledge-grounded reasoning/tool call), confidence-band policy as a DB-
configurable gate applied by app code, a deterministic tool-policy gate
that never reads AI free text, an app-owned runbook step index, and a
fail-closed placeholder verification tool mirroring the existing
malware-scanner precedent. Real Anthropic Claude integration per explicit
product decision.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:50:16 +05:30
saqib mirandClaude Sonnet 5 9586b872b7 docs: spec for AI support agent feature (005)
Phase 4 of the roadmap: AI session/diagnosis, confidence-band policy,
permission/risk-gated tool system, runbook execution, and evidence-based
verification. Per explicit decision, reasoning integrates a real LLM
provider (Anthropic Claude) rather than a mock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 16:43:51 +05:30
saqib mirandClaude Sonnet 5 e33d86081f feat: implement product knowledge management & retrieval (004)
Implements 26 of 28 tasks from specs/004-product-knowledge/tasks.md
across all three user stories -- Phase 3 of the roadmap. First
feature to populate src/modules/ai-support/ (doc 07 places
`knowledge` there; only that submodule is built, matching this
codebase's convention of not pre-building unneeded submodules).

Schema (prisma/schema.prisma + migration):
- KnowledgeEntry, ErrorCode, KnownIssue, Runbook per docs/06,
  refining its conceptual flat `version` field into an explicit
  version-history mechanism: each edit inserts a new row
  (isCurrentVersion flag, compound unique on (code, version) /
  (key, productId, version)) instead of overwriting in place -- the
  only way "prior versions remain retrievable" (FR-004/FR-009) is
  actually true rather than aspirational.

User Story 1 -- knowledge entry authoring/publish/version (P1, MVP):
- draft -> published -> unpublished lifecycle; publish only takes
  effect from its effectiveDate.
- Editing uses the same conditional-update-then-insert optimistic
  concurrency pattern as 003-ticketing's Ticket.version (409 on a
  stale expectedVersion).
- Full version history readable via GET .../versions.

User Story 2 -- error codes, known issues, runbooks (P2):
- ErrorCode + KnownIssue with direct lookup-by-error-code.
- Runbook steps stored as an ordered JSON array, preserved exactly;
  same version-on-edit mechanism as knowledge entries; inactive
  runbooks are indistinguishable from nonexistent ones on lookup.

User Story 3 -- filtered retrieval (P3):
- GET /knowledge/retrieve: product-scoped, excludes draft/
  unpublished/not-yet-effective entries, validated entries ranked
  ahead of unvalidated. Deliberately NOT semantic/vector search --
  doc 11 gap B1 explicitly defers embedding-model choice to the
  future AI-support feature; this is real, usable structured
  filtering a semantic layer can sit in front of later.

Found and fixed one real bug before it reached tests: the retrieval
endpoint initially queried by the raw external product id instead of
resolving it to the internal Product.id first (every other endpoint
in this feature does that resolution) -- would have silently
returned zero results for every caller. Fixed with a lenient
tryResolveProductId (empty array, not 404, for an unregistered
product -- matches the "no matches, never an error" contract).

Deliberately skipped (not forgotten, see checklist notes): the two
planned mock-repository unit-test tasks (T004, T019) -- unlike
003-ticketing's state machine, this feature has no pure-logic
surface to isolate from Prisma; coverage comes entirely from
integration tests instead.

All 13 integration test files in the repo (36 tests, spanning this
feature and every prior one) verified passing together against a
real Postgres/Redis/MinIO -- no regressions. Full quality gate
(typecheck/lint/format/architecture/unit tests) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:55:18 +05:30
saqib mirandClaude Sonnet 5 e10736d82a docs: task breakdown for product knowledge feature
/speckit-tasks output for 004-product-knowledge: 28 tasks across 6
phases. MVP scope is Setup+Foundational+US1 (T001-T010) -- knowledge
entries authored, published, and versioned correctly, before
structured error codes/runbooks/retrieval exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:31:36 +05:30
saqib mirandClaude Sonnet 5 e947ac44b8 docs: plan and design artifacts for product knowledge feature
/speckit-plan output for 004-product-knowledge: technical context and
constitution gate check (all PASS), Phase 0 research (6 decisions:
new-row-per-version instead of in-place overwrite to satisfy history
retention, conditional-update-then-insert concurrency reusing
003-ticketing's optimistic-locking pattern, structured (non-semantic)
filtered retrieval per doc 11 gap B1, known-issue lookup by error
code, creating the ai-support module group for the first time with
only its knowledge submodule populated, and admin auth consistent
with prior features), Phase 1 data model (KnowledgeEntry/ErrorCode/
KnownIssue/Runbook, refining doc 06's conceptual schema with an
explicit version-history mechanism), the admin CRUD + retrieval
contract, and a 6-scenario quickstart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:30:46 +05:30
saqib mirandClaude Sonnet 5 cbe783cbdf docs: spec for product knowledge management & retrieval feature
/speckit-specify output for 004-product-knowledge (roadmap Phase 3):
3 user stories (authoring/versioning/publishing knowledge entries,
structured error-code/known-issue/runbook records, filtered
retrieval), 14 functional requirements. Full semantic/vector
retrieval is explicitly deferred to the future AI-support feature
(doc 11 gap B1) -- this feature's retrieval is structured filtering,
a real usable contract rather than a placeholder. Quality checklist
passes with no NEEDS CLARIFICATION markers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:28:08 +05:30
saqib mirandClaude Sonnet 5 2edfbacf82 feat: implement ticket creation, messages & attachments (003-ticketing)
Implements all 39 tasks from specs/003-ticketing/tasks.md across all
three user stories -- Phase 5 of the roadmap.

Schema (prisma/schema.prisma + migration):
- Ticket (code, status, version for optimistic concurrency,
  idempotencyKey, customerId FK), Problem, TicketMessage,
  TicketAttachment per docs/06, with Product/Category/
  CustomerReference back-relations.

User Story 1 -- ticket/problem creation (P1, MVP):
- Explicit 12-state lifecycle adjacency table
  (ticket-state-machine.ts), not "any transition allowed."
- Ticket code generation (<PRODUCT_CODE>-<YEAR>-<SEQUENCE>) scoped
  by the actual code prefix, not productId -- see the collision bug
  fixed below.
- Idempotency-key enforcement via atomic create-then-catch-conflict
  (never a read-then-write race), completing the FR-012 placeholder
  from 002-saas-integration.
- Explicit-reference-only recurring-problem linking (no fuzzy
  matching -- that's a future AI-support concern).
- POST /v1/support/requests (002-saas-integration) now creates a
  real ticket instead of echoing context back.
- PATCH /tickets/:id/status with expectedVersion-based optimistic
  concurrency (409 on stale version, 400 on an invalid transition).

User Story 2 -- typed messages (P2):
- Message type -> visibleToCustomer mapping is a fixed constant map,
  never caller-supplied; customer-scoped reads filter at the query
  layer so an internal note is never fetched, not just hidden.
- POST/GET /tickets/:id/messages (customer-scoped) and
  GET /agent/tickets/:id/messages (agent-scoped).

User Story 3 -- attachment pipeline (P3):
- Presigned-PUT upload (new getPresignedUploadUrl on the existing
  storageService) -- file bytes never transit this API.
- A MalwareScanner interface with a fail-closed placeholder
  (UnimplementedPlaceholderScanner) since no scanner exists in this
  stack -- it always reports 'infected', never silently 'clean'.
- The existing attachments-queue job stub now actually calls the
  scanner and updates scanStatus; registerAttachmentWorker() is
  wired into bootstrapQueue() (previously defined but never called).
- Downloads are gated on scanStatus === 'clean' -- currently always
  refused until a real scanner replaces the placeholder.
- MinIO added to docker-compose.{test,development}.yml for local/CI
  S3-compatible storage, matching doc 04's explicit guidance.

Two real bugs found and fixed via integration testing against a
live Postgres/Redis/MinIO (not just typechecked):
- Ticket codes could collide across different products: the
  sequence counter was scoped by internal productId, but the code
  column's uniqueness is global, and deriveProductCode's 4-character
  truncation means different products can share a prefix. Fixed by
  counting against the actual code prefix instead.
- Three existing 002-saas-integration integration tests' cleanup
  started failing an FK RESTRICT check once ticket creation was
  wired in (deleting a Product before the Ticket/Problem that now
  reference it). Fixed their afterAll ordering.

All 9 integration test files (24 tests, spanning this feature and
the pre-existing suite) verified passing against real Postgres,
Redis, and MinIO, including a genuine presigned-PUT/GET round trip.
Full quality gate (typecheck/lint/format/architecture/unit tests)
passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 15:21:37 +05:30
saqib mirandClaude Sonnet 5 954152bd7b docs: task breakdown for ticketing feature
/speckit-tasks output for 003-ticketing: 39 tasks across 6 phases.
MVP scope is Setup+Foundational+US1 (T001-T019) -- every trusted
inbound request producing a real, durable, idempotent,
concurrency-safe ticket, before messages or attachments exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:56:05 +05:30
saqib mirandClaude Sonnet 5 4751cf3164 docs: plan and design artifacts for ticketing feature
/speckit-plan output for 003-ticketing: technical context and
constitution gate check (all PASS), Phase 0 research (9 decisions:
ticket code format, explicit 12-state lifecycle transition table,
optimistic concurrency via version column, idempotency-key upsert
reusing 002's CustomerReference pattern, explicit-reference-only
recurring-problem linking, config-driven message visibility mapping,
presigned-PUT attachment pipeline, a fail-closed placeholder malware
scanner since none exists in this stack, and adding MinIO to Docker
Compose for local/test S3-compatible storage), Phase 1 data model
(Ticket/Problem/TicketMessage/TicketAttachment plus the inbound
request -> ticket creation behavior), the lifecycle/messages/
attachments contract, and a 6-scenario quickstart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:53:53 +05:30
saqib mirandClaude Sonnet 5 be2eb9907a docs: spec for ticket creation, messages & attachments feature
/speckit-specify output for 003-ticketing (roadmap Phase 5): 3 user
stories (immediate ticket/problem creation with idempotency, typed
messages with enforced internal-note privacy, secure attachment
pipeline) and 15 functional requirements. Explicitly scoped to Phase
5 only -- investigation/root-cause/solution/resolution (Phase 9) and
AI diagnosis (Phase 4) are out of scope. Quality checklist passes
with no NEEDS CLARIFICATION markers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:46:59 +05:30
saqib mirandClaude Sonnet 5 55253287b3 feat: per-integration and per-user rate limiting (US3) + polish
Implements tasks T026-T032 from specs/002-saas-integration/tasks.md
(User Story 3, P3 - the final piece of this feature) plus Polish.

- New bespoke Redis fixed-window counter (checkRateLimit,
  src/infrastructure/cache/rate-limiter.ts) rather than
  @fastify/rate-limit's default onRequest-stage hook -- that hook
  runs before this feature's preHandler-based auth resolves the
  integration/user identity the limit needs to key on. A second
  preHandler (checkIntegrationRateLimit) runs after
  authenticateProductIntegration on the inbound route, checking the
  integration-level limit then the per-user limit independently,
  each throwing the existing RateLimitError (429
  RATE_LIMIT_EXCEEDED) on breach.
- New integration test (inbound-rate-limit.test.ts) verifies both
  limits are enforced independently against a real Postgres/Redis:
  a throttled user doesn't affect others, and the integration cap
  throttles even when no individual user has hit their own limit.
- Docs: contracts/quickstart updated from the placeholder
  "RATE_LIMITED" code to the actual reused RATE_LIMIT_EXCEEDED code;
  cleaned up a duplicated paragraph in the admin endpoints section;
  added a "SaaS Integration" section to README.md documenting the
  inbound contract, admin routes (and their known auth-stub
  limitation), and how rate limits are configured.

All 32 tasks in tasks.md are now complete -- all three user stories
(P1 trust boundary, P2 admin lifecycle, P3 rate limiting) are
implemented and covered by integration tests verified against a
live database, in addition to unit tests for the crypto/token
primitives. Full quality gate (typecheck/lint/format/architecture/
unit tests) passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-02 14:42:20 +05:30
saqib mirandClaude Sonnet 5 3e2a5b97a3 feat: admin lifecycle endpoints for product integrations (US2)
Implements tasks T021-T025 from specs/002-saas-integration/tasks.md
(User Story 2, P2): an admin can register, rotate, revoke, and change
the status of a ProductIntegration, and retrieve its audit trail.

- ProductIntegrationsService: register (finds-or-creates the Product
  by external id), rotate (dual-credential transition window per
  research.md), revoke, updateStatus, getAuditTrail -- each writes
  its own AuditLog entry via a new shared
  integration-audit-log.repository.ts (extracted from the auth
  plugin, which now reuses it instead of writing to Prisma directly).
- Routes: POST /admin/products/:externalProductId/integration,
  POST/admin/integrations/:id/rotate|revoke, PATCH .../status,
  GET .../audit-trail -- gated by the existing fastify.authenticate
  (human/admin JWT) decorator.
- New integration test (product-integrations-admin.test.ts) covers
  Quickstart Scenarios 5-7 end-to-end against a real Postgres/Redis:
  register+rotate+audit-trail, and revoke-takes-effect-immediately.
  Verified passing against a live database.

Known, pre-existing limitation flagged (not fixed here, out of
scope): fastify.authenticate is currently a no-op stub with no real
JWT verification, so these admin endpoints aren't actually
access-controlled yet -- that depends on the unimplemented
identity/auth module. Documented in the contract and checklist notes
so it isn't mistaken for done.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:57:07 +05:30
saqib mirandClaude Sonnet 5 8d5731340d feat: implement SaaS product integration trust boundary (US1 MVP)
Implements tasks T001-T020 from specs/002-saas-integration/tasks.md
(Setup, Foundational, and User Story 1 - the P1 MVP: every inbound
request is authenticated and trusted before anything happens).
User Story 2 (admin onboarding/rotation/revocation) and User Story 3
(rate limiting) are not yet implemented (T021-T032 remain).

Schema (prisma/schema.prisma + initial migration):
- Replace the placeholder Product model (leftover starter-template
  scaffolding: code/description/ProductStatus enum) with the real
  docs/06-database-schema.md shape (externalProductId,
  supportEnabled, status).
- Add ProductIntegration (credential ref, rotation/revocation state,
  allowed scope, per-integration/per-user rate limits) and
  CustomerReference models.
- Align AuditLog to docs/06's shape (actor/actorType/entityType/
  entityId/reason/metadata) -- the placeholder shape had no fields
  to satisfy this feature's audit requirements.

Auth:
- HMAC-signed short-lived tokens (issue/verify) with jti-based replay
  defense via Redis and a bounded clock-skew tolerance.
- Credential secrets are AES-256-GCM encrypted at rest (new required
  INTEGRATION_CREDENTIAL_ENCRYPTION_KEY env var) since no secret
  manager exists in this stack yet -- see research.md "Credential
  storage".
- New product-integration-auth.plugin.ts Fastify plugin runs the
  validation order in contracts/inbound-request-contract.md and
  populates request.reqContext only on full success; every attempt
  (success or failure) is audit-logged without ever persisting the
  raw token/credential. Unregistered product and invalid credential
  return an identical response (FR-010).
- New POST /v1/support/requests endpoint exercises the boundary
  end-to-end (ticket creation itself is a future feature).

Also:
- Fix docker-compose.test.yml's container_name collisions --
  discovered while testing this change concurrently is now covered
  by an app-level regression test (separate commit).
- Fix test:unit to scope to tests/unit only (it was running the
  entire tests/** glob including integration tests) -- this feature's
  new integration test makes real Prisma/Redis calls, unlike the
  prior instantiation-only checks, so the existing glob-scoping gap
  became actually harmful.
- Update Jenkinsfile with the new required credential.

Verified: full quality gate (typecheck/lint/format/architecture/
unit tests) passes; all of User Story 1's quickstart scenarios
manually verified end-to-end against a live server + Postgres +
Redis; the new integration test suite verified against a live
database (not run as part of `npm test`, matches existing
test:integration convention).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:40:43 +05:30
saqib mirandClaude Sonnet 5 5444fb7ef3 fix: setErrorHandler must be registered before route modules
Fastify resolves each encapsulated child context's error handler at
the time that context is registered. app.ts called
app.setErrorHandler()/setNotFoundHandler() AFTER bootstrapRoutes()
had already registered every domain module's routes (each
app.register(someRoutes) call creates its own child context, since
none of the route modules use fastify-plugin). A handler set on the
parent afterwards does not retroactively apply to already-registered
children, so every module's routes were silently falling back to
Fastify's default {statusCode, error, message} error shape instead
of this app's {success:false, error:{code,message,details},
requestId} envelope, for any thrown error -- not specific to any one
feature. Discovered while building and manually verifying the
002-saas-integration feature's inbound endpoint.

Also fixed the generic error-handler branch to preserve a framework
error's own client-facing statusCode (e.g. 400 for malformed JSON)
instead of always reporting 500.

Added a regression test in tests/unit/app.test.ts that fails without
this fix and passes with it (verified both ways).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 18:40:20 +05:30
saqib mirandClaude Sonnet 5 000a8bcb6b docs: task breakdown for SaaS integration feature
/speckit-tasks output for 002-saas-integration: 32 tasks across 6
phases. Unlike 001-ci-pipeline, this feature includes test tasks as
first-class (not optional) since it's a security boundary. MVP scope
is Setup+Foundational+US1 (T001-T020) - the inbound trust boundary
alone, before admin lifecycle tooling or rate limiting exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 17:51:06 +05:30
saqib mirandClaude Sonnet 5 a7216b7531 docs: plan and design artifacts for SaaS integration feature
/speckit-plan output for 002-saas-integration: technical context and
constitution gate check (all PASS), Phase 0 research (9 decisions:
signed-token format, replay resistance via Redis jti tracking, clock
skew tolerance, rotation via previous-credential transition window,
per-integration/per-user rate limiting, strict unknown-field
rejection, non-leaking error responses, and reconciling the
placeholder Prisma schema with docs/06's real Product shape), Phase 1
data model (Product revision, ProductIntegration, CustomerReference,
AuditLog reuse for auth events), the inbound request validation
contract (9 fixed steps + admin lifecycle endpoints), and an
8-scenario quickstart.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 17:45:02 +05:30
saqib mirandClaude Sonnet 5 0e92faf615 docs: spec for SaaS integration & inbound request trust feature
/speckit-specify output for 002-saas-integration (roadmap Phase 2):
3 user stories (authenticate/validate inbound requests, admin
onboarding/rotation/revocation, rate limiting) and 12 functional
requirements. Reserves an idempotency-key field on the inbound
contract for the future ticketing feature (docs/11 gap A1) without
implementing dedup here. Quality checklist passes with no
NEEDS CLARIFICATION markers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 17:39:25 +05:30
saqib mirandClaude Sonnet 5 38148a97f9 docs: add architect's additions doc (gaps & recommendations)
Production concerns not covered by the original spec (00-10):
idempotency on ticket creation, bi-directional webhook callbacks,
row-level-security tenant isolation, AI prompt-injection defense,
optimistic concurrency on shared mutable state, RAG implementation
specifics, AI cost/token governance, knowledge effectiveness
feedback, CSAT capture, data retention/PII, API versioning/error
contract, localization, and a lower-urgency list. Indexed in
docs/00-INDEX.md as doc 11.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 17:35:32 +05:30
saqib mirandClaude Sonnet 5 2dffe58496 feat: implement CI pipeline (Jenkinsfile) for 001-ci-pipeline
Implements tasks T001-T016, T018-T022, T024-T026 from
specs/001-ci-pipeline/tasks.md (T017/T023 need a real Jenkins
instance to verify and are left for manual follow-up).

- Add Jenkinsfile: checkout -> install -> environment validation
  -> typecheck -> lint (+ architecture check) -> format check ->
  unit -> integration -> E2E -> build -> Docker build -> publish
  -> deploy, matching the constitution's required stage order.
  Secrets are always injected from Jenkins credentials at runtime,
  never read from a repo-committed file. Publish/Deploy are skipped
  (not failed) on branches with no resolved deploy target.
- Fix docker-compose.test.yml: remove fixed container_name on
  app/postgres/redis, which would have made concurrent CI runs
  collide (FR-009). Verified locally that two runs under different
  -p project names no longer share container/volume/network names.
- Document the pipeline and local .env setup in README.md.
- Mark completed tasks in specs/001-ci-pipeline/tasks.md and record
  the container_name/compose-down-env-file findings in the spec's
  requirements checklist notes.

Locally verified passing: Dockerfile build, typecheck, lint,
architecture check, format check, unit test suite, and the edited
docker-compose.test.yml bringing up postgres/redis with isolated
per-project container names.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 17:25:46 +05:30
saqib mirandClaude Sonnet 5 dd2d803c93 docs: task breakdown for CI pipeline feature
/speckit-tasks output for 001-ci-pipeline: 26 tasks across 5 phases
(Setup, Foundational, US1 validate/build, US2 publish/deploy, Polish),
with the MVP scope being US1 alone (T001-T017).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 17:15:46 +05:30
saqib mirandClaude Sonnet 5 414f336704 docs: spec, plan, and design artifacts for CI pipeline feature
/speckit-specify + /speckit-plan output for 001-ci-pipeline: feature
spec with 2 user stories and 10 functional requirements, requirements
quality checklist, implementation plan with constitution gate check,
Phase 0 research (6 decisions incl. secrets-from-credentials-store),
Phase 1 data model, pipeline stage contract, and a 5-scenario
quickstart validation guide. No Jenkinsfile yet — that's the
implementation step after /speckit-tasks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:22:29 +05:30
saqib mirandClaude Sonnet 5 f475a55a53 docs: add product and engineering specification
Full system blueprint (docs 01-10): product vision, integration &
security, AI support architecture, ticketing & problem management,
orchestration/SLA/escalation, database schema, backend/frontend
architecture, testing/observability/CI-CD, and the implementation
roadmap. This is the pre-implementation design reference the codebase
is being built against.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:22:21 +05:30
saqib mirandClaude Sonnet 5 c61b78fcd1 chore: install GitHub Spec Kit tooling
Adds the specify CLI's .specify/ scaffolding (templates, PowerShell
automation scripts, constitution memory) and the .claude/skills/speckit-*
slash commands for spec-driven development (constitution, specify,
clarify, plan, tasks, checklist, analyze, implement, converge).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:22:13 +05:30
saqib mirandClaude Sonnet 5 2093898198 fix: stop tracking .env files with committed secrets
.env.development had a real Postgres/Redis password and JWT secret
committed in plain text; .env.test and .env.prod were tracked too.
Untrack all .env* files going forward and add .env.example as the
onboarding template instead.

Note: the exposed dev credentials are still in git history and must
be rotated separately.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 16:10:32 +05:30
679 changed files with 46773 additions and 429 deletions
+262
View File
@@ -0,0 +1,262 @@
---
name: "speckit-analyze"
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
argument-hint: "Optional focus areas for analysis"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/analyze.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before analysis)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
## Execution Steps
### 1. Initialize Analysis Context
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Overview/Context
- Functional Requirements
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
- User Stories
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices
- Data Model references
- Phases
- Technical constraints
**From tasks.md:**
- Task IDs
- Descriptions
- Phase grouping
- Parallel markers [P]
- Referenced file paths
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
### 3. Build Semantic Models
Create internal representations (do not include raw artifacts in output):
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
- **User story/action inventory**: Discrete user actions with acceptance criteria
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
### 4. Detection Passes (Token-Efficient Analysis)
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
#### A. Duplication Detection
- Identify near-duplicate requirements
- Mark lower-quality phrasing for consolidation
#### B. Ambiguity Detection
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
#### C. Underspecification
- Requirements with verbs but missing object or measurable outcome
- User stories missing acceptance criteria alignment
- Tasks referencing files or components not defined in spec/plan
#### D. Constitution Alignment
- Any requirement or plan element conflicting with a MUST principle
- Missing mandated sections or quality gates from constitution
#### E. Coverage Gaps
- Requirements with zero associated tasks
- Tasks with no mapped requirement/story
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
#### F. Inconsistency
- Terminology drift (same concept named differently across files)
- Data entities referenced in plan but absent in spec (or vice versa)
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
### 5. Severity Assignment
Use this heuristic to prioritize findings:
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
### 6. Produce Compact Analysis Report
Output a Markdown report (no file writes) with the following structure:
## Specification Analysis Report
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|----|----------|----------|-------------|---------|----------------|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
(Add one row per finding; generate stable IDs prefixed by category initial.)
**Coverage Summary Table:**
| Requirement Key | Has Task? | Task IDs | Notes |
|-----------------|-----------|----------|-------|
**Constitution Alignment Issues:** (if any)
**Unmapped Tasks:** (if any)
**Metrics:**
- Total Requirements
- Total Tasks
- Coverage % (requirements with >=1 task)
- Ambiguity Count
- Duplication Count
- Critical Issues Count
### 7. Provide Next Actions
At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
### 9. Check for extension hooks
After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Operating Principles
### Context Efficiency
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
### Analysis Guidelines
- **NEVER modify files** (this is read-only analysis)
- **NEVER hallucinate missing sections** (if absent, report them accurately)
- **Prioritize constitution violations** (these are always CRITICAL)
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
- **Report zero issues gracefully** (emit success report with coverage statistics)
## Context
$ARGUMENTS
+386
View File
@@ -0,0 +1,386 @@
---
name: "speckit-checklist"
description: "Generate a custom checklist for the current feature based on user requirements."
argument-hint: "Domain or focus area for the checklist"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/checklist.md"
user-invocable: true
disable-model-invocation: false
---
## Checklist Purpose: "Unit Tests for English"
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
**NOT for verification/testing**:
- ❌ NOT "Verify the button clicks correctly"
- ❌ NOT "Test error handling works"
- ❌ NOT "Confirm the API returns 200"
- ❌ NOT checking if code/implementation matches the spec
**FOR requirements quality validation**:
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
**Ownership and checkbox lifecycle**:
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
- `[x]` does NOT mean implementation work is complete.
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; do not treat that exception as applying to custom checklists generated here.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before checklist generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Execution Steps.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Execution Steps
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
- Only ask about information that materially changes checklist content
- Be skipped individually if already unambiguous in `$ARGUMENTS`
- Prefer precision over breadth
Generation algorithm:
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
5. Formulate questions chosen from these archetypes:
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- Limit to AE options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
Defaults when interaction impossible:
- Depth: Standard
- Audience: Reviewer (PR) if code-related; Author otherwise
- Focus: Top 2 relevance clusters
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted followups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
- Derive checklist theme (e.g., security, review, deploy, ux)
- Consolidate explicit must-have items mentioned by user
- Map focus selections to category scaffolding
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
5. **Load feature context**: Read from FEATURE_DIR:
- spec.md: Feature requirements and scope
- plan.md (if exists): Technical details, dependencies
- tasks.md (if exists): Implementation tasks
**Context Loading Strategy**:
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
- Prefer summarizing long sections into concise scenario/requirement bullets
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
- Format: `[domain].md`
- File handling behavior:
- If file does NOT exist: Create new file and number items starting from CHK001
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
- Never delete or replace existing checklist content - always preserve and append
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
- **Completeness**: Are all necessary requirements present?
- **Clarity**: Are requirements unambiguous and specific?
- **Consistency**: Do requirements align with each other?
- **Measurability**: Can requirements be objectively verified?
- **Coverage**: Are all scenarios/edge cases addressed?
**Category Structure** - Group items by requirement quality dimensions:
- **Requirement Completeness** (Are all necessary requirements documented?)
- **Requirement Clarity** (Are requirements specific and unambiguous?)
- **Requirement Consistency** (Do requirements align without conflicts?)
- **Acceptance Criteria Quality** (Are success criteria measurable?)
- **Scenario Coverage** (Are all flows/cases addressed?)
- **Edge Case Coverage** (Are boundary conditions defined?)
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
- **Dependencies & Assumptions** (Are they documented and validated?)
- **Ambiguities & Conflicts** (What needs clarification?)
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
❌ **WRONG** (Testing implementation):
- "Verify landing page displays 3 episode cards"
- "Test hover states work on desktop"
- "Confirm logo click navigates home"
✅ **CORRECT** (Testing requirements quality):
- "Are the exact number and layout of featured episodes specified?" [Completeness]
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
- "Are loading states defined for asynchronous episode data?" [Completeness]
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
**ITEM STRUCTURE**:
Each item should follow this pattern:
- Question format asking about requirement quality
- Focus on what's WRITTEN (or not written) in the spec/plan
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
- Reference spec section `[Spec §X.Y]` when checking existing requirements
- Use `[Gap]` marker when checking for missing requirements
**EXAMPLES BY QUALITY DIMENSION**:
Completeness:
- "Are error handling requirements defined for all API failure modes? [Gap]"
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
Clarity:
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
Consistency:
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
Coverage:
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
Measurability:
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
**Scenario Classification & Coverage** (Requirements Quality Focus):
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
**Traceability Requirements**:
- MINIMUM: ≥80% of items MUST include at least one traceability reference
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
**Surface & Resolve Issues** (Requirements Quality Problems):
Ask questions about the requirements themselves:
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
**Content Consolidation**:
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
- Merge near-duplicates checking the same requirement aspect
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
- ❌ References to code execution, user actions, system behavior
- ❌ "Displays correctly", "works properly", "functions as expected"
- ❌ "Click", "navigate", "render", "load", "execute"
- ❌ Test cases, test plans, QA procedures
- ❌ Implementation details (frameworks, APIs, algorithms)
**✅ REQUIRED PATTERNS** - These test requirements quality:
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
- ✅ "Are requirements consistent between [section A] and [section B]?"
- ✅ "Can [requirement] be objectively measured/verified?"
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
- ✅ "Does the spec define [missing aspect]?"
7. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `/speckit-implement` reads checklist state but does not modify markers.
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
- Focus areas selected
- Depth level
- Actor/timing
- Any explicit user-specified must-have items incorporated
**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose
- Easy identification and navigation in the `checklists/` folder
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
## Example Checklist Types & Sample Items
**UX Requirements Quality:** `ux.md`
Sample items (testing the requirements, NOT the implementation):
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
**API Requirements Quality:** `api.md`
Sample items:
- "Are error response formats specified for all failure scenarios? [Completeness]"
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
- "Are authentication requirements consistent across all endpoints? [Consistency]"
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
- "Is versioning strategy documented in requirements? [Gap]"
**Performance Requirements Quality:** `performance.md`
Sample items:
- "Are performance requirements quantified with specific metrics? [Clarity]"
- "Are performance targets defined for all critical user journeys? [Coverage]"
- "Are performance requirements under different load conditions specified? [Completeness]"
- "Can performance requirements be objectively measured? [Measurability]"
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
**Security Requirements Quality:** `security.md`
Sample items:
- "Are authentication requirements specified for all protected resources? [Coverage]"
- "Are data protection requirements defined for sensitive information? [Completeness]"
- "Is the threat model documented and requirements aligned to it? [Traceability]"
- "Are security requirements consistent with compliance obligations? [Consistency]"
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
## Anti-Examples: What NOT To Do
**❌ WRONG - These test implementation, not requirements:**
```markdown
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
```
**✅ CORRECT - These test requirements quality:**
```markdown
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
```
**Key Differences:**
- Wrong: Tests if the system works correctly
- Correct: Tests if the requirements are written correctly
- Wrong: Verification of behavior
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"
## Post-Execution Checks
**Check for extension hooks (after checklist generation)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+294
View File
@@ -0,0 +1,294 @@
---
name: "speckit-clarify"
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
argument-hint: "Optional areas to clarify in the spec"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/clarify.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before clarification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_clarify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
Functional Scope & Behavior:
- Core user goals & success criteria
- Explicit out-of-scope declarations
- User roles / personas differentiation
Domain & Data Model:
- Entities, attributes, relationships
- Identity & uniqueness rules
- Lifecycle/state transitions
- Data volume / scale assumptions
Interaction & UX Flow:
- Critical user journeys / sequences
- Error/empty/loading states
- Accessibility or localization notes
Non-Functional Quality Attributes:
- Performance (latency, throughput targets)
- Scalability (horizontal/vertical, limits)
- Reliability & availability (uptime, recovery expectations)
- Observability (logging, metrics, tracing signals)
- Security & privacy (authN/Z, data protection, threat assumptions)
- Compliance / regulatory constraints (if any)
Integration & External Dependencies:
- External services/APIs and failure modes
- Data import/export formats
- Protocol/versioning assumptions
Edge Cases & Failure Handling:
- Negative scenarios
- Rate limiting / throttling
- Conflict resolution (e.g., concurrent edits)
Constraints & Tradeoffs:
- Technical constraints (language, storage, hosting)
- Explicit tradeoffs or rejected alternatives
Terminology & Consistency:
- Canonical glossary terms
- Avoided synonyms / deprecated terms
Completion Signals:
- Acceptance criteria testability
- Measurable Definition of Done style indicators
Misc / Placeholders:
- TODO markers / unresolved decisions
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
For each category with Partial or Missing status, add a candidate question opportunity unless:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
- Maximum of 5 total questions across the whole session.
- Each question must be answerable with EITHER:
- A short multiplechoice selection (25 distinct, mutually exclusive options), OR
- A one-word / shortphrase answer (explicitly constrain: "Answer in <=5 words").
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
5. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- **Question writing quality (applies to every question, MC or short-answer):**
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
- For multiplechoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
- Common patterns in similar implementations
- Risk reduction (security, performance, maintainability)
- Alignment with any explicit project goals or constraints visible in the spec
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
- Format as: `**Recommended:** Option [X] - <reasoning>`
- Then render all options as a Markdown table:
| Option | Description |
|--------|-------------|
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> (add D/E as needed up to 5) |
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
- For shortanswer style (no meaningful discrete options):
- Provide your **suggested answer** based on best practices and context.
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
- After the user answers:
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
- Stop asking further questions when:
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
- User signals completion ("done", "good", "no more"), OR
- You reach 5 asked questions.
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
6. Integration after EACH accepted answer (incremental update approach):
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
- For the first integrated answer in this session:
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
- Then immediately apply the clarification to the most appropriate section(s):
- Functional ambiguity → Update or add a bullet in Functional Requirements.
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
7. Validation (performed after EACH write plus final pass):
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
- Total asked (accepted) questions ≤ 5.
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
8. Write the updated spec back to `FEATURE_SPEC`.
9. **Re-validate Spec Quality Checklist** (if it exists):
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
- If it does NOT exist, skip this step silently.
- If it exists:
1. Read the checklist file.
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
5. For each checkbox item, update only if the checked/unchecked state actually changes:
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
- **Newly passing**: items that changed from unchecked to checked.
- **Regressions**: items that changed from checked to unchecked.
- **Still unchecked**: items that remain unchecked.
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: $ARGUMENTS
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
- Suggested next command.
## Done When
- [ ] Spec ambiguities identified and clarifications integrated into spec file
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
@@ -0,0 +1,180 @@
---
name: "speckit-constitution"
description: "Create or update the project constitution from interactive or provided principle inputs."
argument-hint: "Principles or values for the project constitution"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/constitution.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Scope Guard
This command's own work is limited to updating the project constitution itself. Dependent templates
and commands read the constitution at runtime and are not modified here.
- Classify every part of the user input as either constitution content or a separate,
non-governance intent.
- If the input includes feature implementation, code generation, refactoring, building, or
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
- You **MUST NOT** create, modify, or delete application source files, feature routes,
components, tests, deployment files, or other artifacts unrelated to the constitution
workflow.
- If it is unclear whether an instruction is constitution content, ask for clarification before
making changes.
- After completing the constitution update, include a `Next Actions` section for each deferred
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
as `/speckit-specify`, without invoking it.
- If there are no non-governance intents, omit the `Next Actions` section.
## Pre-Execution Checks
**Check for extension hooks (before constitution update)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_constitution` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. The active
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
preset/template resolution stack.
Follow this execution flow:
1. Run `.specify/scripts/powershell/resolve-template.ps1 constitution-template -Json` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
- The shared resolver applies project overrides, composing preset layers, and extension layers
before the core template fallback. It MUST succeed before continuing.
- If it fails, stop and report the resolution error; do not continue with only one contributing
template layer.
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
values and amendments. Preserve information that is still applicable when applying the newly
resolved scaffold.
- If it does not exist, use the resolved template as the initial document.
- Do not write back to any versioned template layer.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
2. Collect/derive values for placeholders:
- If user input (conversation) supplies a value, use it.
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
- MINOR: New principle/section added or materially expanded guidance.
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content using the resolved template as the required structure:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Follow-up TODOs if any placeholders intentionally deferred.
5. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
7. Output a final summary to the user with:
- New version and bump rationale.
- Any TODO placeholders or deferred items requiring manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
- A `Next Actions` section for any deferred non-governance intents.
Formatting & Style Requirements:
- Use Markdown headings exactly as in the template (do not demote/promote levels).
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
- Keep a single blank line between sections.
- Avoid trailing whitespace.
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
## Post-Execution Checks
**Check for extension hooks (after constitution update)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_constitution` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+279
View File
@@ -0,0 +1,279 @@
---
name: "speckit-converge"
description: "Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it."
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/converge.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before convergence)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_converge` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```text
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```text
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
Close the gap between what a feature's specification, plan, and tasks call for and what the
codebase currently implements. Read `spec.md`, `plan.md`, and `tasks.md` as the **sole
source of intent** (with the constitution as governing constraints), assess the current
state of the code, determine which requirements, acceptance criteria, plan decisions, and
existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece
of remaining work as a new, traceable task** at the bottom of `tasks.md` so that
`/speckit-implement` can complete it. This command MUST run only after
`/speckit-implement` has run on the current `tasks.md`, and after `/speckit-tasks` has produced a complete `tasks.md`.
This is **not** a diff tool and does **not** track changes. It assesses the present state
of the code relative to the feature's artifacts — no git, no branch comparison, no history.
## Operating Constraints
**APPEND-ONLY, NEVER REWRITE**: The command's **only** write is appending a new
`## Phase N: Convergence` section to `tasks.md`. It MUST NOT:
- modify `spec.md` or `plan.md` in any way;
- rewrite, renumber, reorder, or delete any existing task (including tasks from a prior
Convergence phase);
- modify, create, or delete any application code — completing the appended tasks is the
job of `/speckit-implement`.
When the codebase already satisfies everything, the command MUST leave `tasks.md`
**byte-for-byte unchanged** (no empty Convergence header) and report a clean result.
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is
**non-negotiable**. Code that violates a MUST principle is the highest-severity finding and
produces a corresponding remediation task. If the constitution is an unfilled template,
skip constitution checks gracefully rather than failing.
## Execution Steps
### 1. Initialize Convergence Context
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
- CONSTITUTION = `.specify/memory/constitution.md` (if present)
If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the
prerequisite command to run (`/speckit-specify` for a missing spec, `/speckit-plan` for a missing plan,
`/speckit-tasks` for missing tasks). Do not produce partial output.
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Functional Requirements (FR-###)
- Success Criteria (SC-###) — include only items requiring buildable work; exclude
post-launch outcome metrics and business KPIs
- User Stories and their Acceptance Scenarios
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices and technical decisions
- Data Model references
- Phases and named touch-points (files/components the plan says will be created or edited)
- Technical constraints
**From tasks.md:**
- Task IDs (to compute the next ID and next phase number)
- Descriptions, phase grouping, and referenced file paths
**From constitution (if not an unfilled template):**
- Principle names and MUST/SHOULD normative statements
### 3. Build the Intent Inventory
Create an internal model (do not echo raw artifacts):
- **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance
scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that
impose buildable obligations.
- **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword
search for the concepts each requirement describes, derive the set of source files and
components in scope for assessment. Bound the assessment to these — do **not** infer
scope beyond what the artifacts define.
### 4. Assess the Codebase and Classify Findings
For each item in the intent inventory, inspect the current code in scope and produce a
`Finding` only where there is a gap. Classify every finding by **gap type**:
- **`missing`**: the required work is absent from the code entirely.
- **`partial`**: the work exists but does not yet fully satisfy the requirement /
acceptance criterion / plan decision.
- **`contradicts`**: the code does something that conflicts with stated intent or a
constitution MUST principle.
- **`unrequested`**: the code contains work not called for by the spec, plan, or tasks
(surfaced for awareness — converge does **not** delete code, it only appends a task to
review/justify or remove it).
Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a
severity, and a short human-readable description with the evidence (the file/area observed).
**Edge cases:**
- **Little or no code yet**: treat the entire specified scope as `missing` remaining work
rather than failing.
- **Nothing remains**: produce zero findings and follow the converged branch in Step 7.
### 5. Assign Severity
- **CRITICAL**: violates a constitution MUST principle, or a `missing`/`contradicts` gap
that blocks baseline functionality of a P1 user story.
- **HIGH**: a `missing` or `partial` gap on a core functional requirement or acceptance
criterion.
- **MEDIUM**: a `partial` gap on a secondary requirement, or an `unrequested` addition with
unclear justification.
- **LOW**: minor partial gaps, polish, or low-risk `unrequested` additions.
### 6. Present the In-Session Findings Summary
Before appending anything, output a compact, severity-graded summary (no file writes yet):
## Convergence Findings
| ID | Gap Type | Severity | Source | Evidence | Remaining Work |
|----|----------|----------|--------|----------|----------------|
| F1 | missing | HIGH | FR-008 | Example: no append-only guard detected in path/to/module.py when writing tasks.md | Add append-only enforcement |
**Summary metrics:**
- Requirements / acceptance criteria checked
- Plan decisions checked
- Constitution principles checked (or "skipped — template")
- Findings by gap type (missing / partial / contradicts / unrequested)
- Findings by severity
### 7. Append Convergence Tasks (or report converged)
**If there are one or more actionable findings** (`tasks_appended` outcome):
Append to the **end** of `tasks.md`, per the append contract:
1. Scan all existing task IDs; let `M` be the maximum. Determine the next phase number `N`
(highest existing phase + 1).
2. Write a single new section header `## Phase N: Convergence`.
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
```markdown
- [ ] T042 <imperative description> per <source-ref> (<gap-type>)
```
`<source-ref>` traces the task to its origin: e.g. `FR-003`, `SC-002`,
`US1/AC2`, `plan: storage decision`, `Constitution II`.
`<gap-type>` is one of `missing`, `partial`, `contradicts`, `unrequested`.
Constitution-violation tasks MUST be emitted first and described as
`CRITICAL`.
4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new,
separately-numbered one below it — do not touch the old one.
**If there are no actionable findings** (`converged` outcome):
- Do **not** modify `tasks.md` at all — no empty phase header.
- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."**
- Include the summary counts of what was checked.
### 8. Provide Next Actions (Handoff)
- On `tasks_appended`: state how many tasks were appended under which phase, and recommend
running `/speckit-implement` to complete them; note that a follow-up converge
run will find fewer or no remaining items.
- On `converged`: recommend proceeding to review / opening a PR. No further implement pass
is needed for this feature's specified scope.
### 9. Check for extension hooks
After producing the result, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_converge` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing
any hooks, so users can decide whether to run optional follow-up commands.
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```text
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```text
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+229
View File
@@ -0,0 +1,229 @@
---
name: "speckit-implement"
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
argument-hint: "Optional implementation guidance or task filter"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/implement.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before implementation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; custom checklists generated by `/speckit-checklist` are reviewer-owned requirements-quality review artifacts
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
- Scan all checklist files in the checklists/ directory
- For each checklist, count:
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
- Checked items: Lines matching `- [X]` or `- [x]`
- Unchecked items: Lines matching `- [ ]`
- Create a status table:
```text
| Checklist | Total | Checked | Unchecked | Status |
|-----------|-------|---------|-----------|--------|
| ux.md | 12 | 12 | 0 | ✓ PASS |
| test.md | 8 | 5 | 3 | ✗ FAIL |
| security.md | 6 | 6 | 0 | ✓ PASS |
```
- Calculate overall status:
- **PASS**: All checklists have 0 unchecked items
- **FAIL**: One or more checklists have unchecked items
- **If any checklist has unchecked items**:
- Display the table with unchecked item counts
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
- Wait for user response before continuing
- If user says "no" or "wait" or "stop", halt execution
- If user says "yes" or "proceed" or "continue", proceed to step 3
- **If all checklists are checked**:
- Display the table showing all checklists passed
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
**Detection & Creation Logic**:
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
```sh
git rev-parse --git-dir 2>/dev/null
```
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
- Check if .eslintrc* exists → create/verify .eslintignore
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
- Check if .prettierrc* exists → create/verify .prettierignore
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
- Check if terraform files (*.tf) exist → create/verify .terraformignore
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
**If ignore file missing**: Create with full pattern set for detected technology
**Common Patterns by Technology** (from plan.md tech stack):
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
**Tool-Specific Patterns**:
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
5. Parse tasks.md structure and extract:
- **Task phases**: Setup, Tests, Core, Integration, Polish
- **Task dependencies**: Sequential vs parallel execution rules
- **Task details**: ID, description, file paths, parallel markers [P]
- **Execution flow**: Order and dependency requirements
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services
- **Polish and validation**: Unit tests, performance optimization, documentation
8. Progress tracking and error handling:
- Report progress after each completed task
- Halt execution if any non-parallel task fails
- For parallel tasks [P], continue with successful tasks, report failed ones
- Provide clear error messages with context for debugging
- Suggest next steps if implementation cannot proceed
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
9. Completion validation:
- Verify all required tasks are completed
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_implement` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report final status with summary of completed work.
## Done When
- [ ] All tasks in tasks.md completed and marked `[X]`
- [ ] Implementation validated against specification, plan, and test coverage
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with summary of completed work
+169
View File
@@ -0,0 +1,169 @@
---
name: "speckit-plan"
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
argument-hint: "Optional guidance for the planning phase"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/plan.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before planning)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
- Fill Constitution Check section from constitution
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Re-evaluate Constitution Check post-design
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_plan` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases
### Phase 0: Outline & Research
1. **Extract unknowns from Technical Context** above:
- For each NEEDS CLARIFICATION → research task
- For each dependency → best practices task
- For each integration → patterns task
2. **Generate and dispatch research agents**:
```text
For each unknown in Technical Context:
Task: "Research {unknown} for {feature context}"
For each technology choice:
Task: "Find best practices for {tech} in {domain}"
```
3. **Consolidate findings** in `research.md` using format:
- Decision: [what was chosen]
- Rationale: [why chosen]
- Alternatives considered: [what else evaluated]
**Output**: research.md with all NEEDS CLARIFICATION resolved
### Phase 1: Design & Contracts
**Prerequisites:** `research.md` complete
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Create quickstart validation guide** → `quickstart.md`:
- Document runnable validation scenarios that prove the feature works end-to-end
- Include prerequisites, setup commands, test/run commands, and expected outcomes
- Use links or references to contracts and data model details instead of duplicating them
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
**Output**: data-model.md, /contracts/*, quickstart.md
## Key rules
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
- ERROR on gate failures or unresolved clarifications
## Done When
- [ ] Plan workflow executed and design artifacts generated
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with branch, plan path, and generated artifacts
+348
View File
@@ -0,0 +1,348 @@
---
name: "speckit-specify"
description: "Create or update the feature specification from a natural language feature description."
argument-hint: "Describe the feature you want to specify"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/specify.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before specification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
1. **Generate a concise short name** (2-4 words) for the feature:
- Analyze the feature description and extract the most meaningful keywords
- Create a 2-4 word short name that captures the essence of the feature
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
- Keep it concise but descriptive enough to understand the feature at a glance
- Examples:
- "I want to add user authentication" → "user-auth"
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Branch creation** (optional, via hook):
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
3. **Create the spec feature directory**:
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
2. Otherwise, auto-generate it under `specs/`:
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
**Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
- Persist the resolved path to `.specify/feature.json`:
```json
{
"feature_directory": "<resolved feature dir>"
}
```
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
**IMPORTANT**:
- You must only create one feature per `/speckit-specify` invocation
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook
4. Load the resolved active `spec-template` file to understand required sections.
5. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
6. Follow this execution flow:
1. Parse user description from arguments
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
Identify: actors, actions, data, constraints
3. For unclear aspects:
- Make informed guesses based on context and industry standards
- Only mark with [NEEDS CLARIFICATION: specific question] if:
- The choice significantly impacts feature scope or user experience
- Multiple reasonable interpretations exist with different implications
- No reasonable default exists
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
4. Fill User Scenarios & Testing section
If no clear user flow: ERROR "Cannot determine user scenarios"
5. Generate Functional Requirements
Each requirement must be testable
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
6. Define Success Criteria
Create measurable, technology-agnostic outcomes
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
Each criterion must be verifiable without implementation details
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
- [ ] Success criteria are technology-agnostic (no implementation details)
- [ ] All acceptance scenarios are defined
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
```
b. **Run Validation Check**: Review the spec against each checklist item:
- For each item, determine if it passes or fails
- Document specific issues found (quote relevant spec sections)
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
2. Update the spec to address each issue
3. Re-run validation until all items pass (max 3 iterations)
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
- **If [NEEDS CLARIFICATION] markers remain**:
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
3. For each clarification needed (max 3), present options to user in this format:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
- Use consistent spacing with pipes aligned
- Each cell should have spaces around content: `| Content |` not `|Content|`
- Header separator must have at least 3 dashes: `|--------|`
- Test that the table renders correctly in markdown preview
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
6. Present all questions together before waiting for responses
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
9. Re-run validation after all clarifications are resolved
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_specify` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report completion to the user with:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path
- Checklist results summary
- Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
## Quick Guidelines
- Focus on **WHAT** users need and **WHY**.
- Avoid HOW to implement (no tech stack, APIs, code structure).
- Written for business stakeholders, not developers.
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
### Section Requirements
- **Mandatory sections**: Must be completed for every feature
- **Optional sections**: Include only when relevant to the feature
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
### For AI Generation
When creating this spec from a user prompt:
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
- Significantly impact feature scope or user experience
- Have multiple reasonable interpretations with different implications
- Lack any reasonable default
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
6. **Common areas needing clarification** (only if no reasonable default exists):
- Feature scope and boundaries (include/exclude specific use cases)
- User types and permissions (if multiple conflicting interpretations possible)
- Security/compliance requirements (when legally/financially significant)
**Examples of reasonable defaults** (don't ask about these):
- Data retention: Industry-standard practices for the domain
- Performance targets: Standard web/mobile app expectations unless specified
- Error handling: User-friendly messages with appropriate fallbacks
- Authentication method: Standard session-based or OAuth2 for web apps
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
### Success Criteria Guidelines
Success criteria must be:
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
4. **Verifiable**: Can be tested/validated without knowing implementation details
**Good examples**:
- "Users can complete checkout in under 3 minutes"
- "System supports 10,000 concurrent users"
- "95% of searches return results in under 1 second"
- "Task completion rate improves by 40%"
**Bad examples** (implementation-focused):
- "API response time is under 200ms" (too technical, use "Users see results instantly")
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
- "React components render efficiently" (framework-specific)
- "Redis cache hit rate above 80%" (technology-specific)
## Done When
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with feature directory, spec file path, and checklist results
+217
View File
@@ -0,0 +1,217 @@
---
name: "speckit-tasks"
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
argument-hint: "Optional task generation constraints"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/tasks.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before tasks generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. **Setup**: Run `.specify/scripts/powershell/setup-tasks.ps1 -Json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
- **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints
- Note: Not all projects have all documents. Generate tasks based on what's available.
3. **Execute task generation workflow**:
- Load plan.md and extract tech stack, libraries, project structure
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
- If data-model.md exists: Extract entities and map to user stories
- If contracts/ exists: Map interface contracts to user stories
- If research.md exists: Extract decisions for setup tasks
- Generate tasks organized by user story (see Task Generation Rules below)
- Generate dependency graph showing user story completion order
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
- Phase 3+: One phase per user story (in priority order from spec.md)
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
- Final Phase: Polish & cross-cutting concerns
- All tasks must follow the strict checklist format (see Task Generation Rules below)
- Clear file paths for each task
- Dependencies section showing story completion order
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
Context for task generation: $ARGUMENTS
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
## Task Generation Rules
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
### Checklist Format (REQUIRED)
Every task MUST strictly follow this format:
```text
- [ ] [TaskID] [P?] [Story?] Description with file path
```
**Format Components**:
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path
**Examples**:
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
### Task Organization
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
- Each user story (P1, P2, P3...) gets its own phase
- Map all related components to their story:
- Models needed for that story
- Services needed for that story
- Interfaces/UI needed for that story
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
- Map each interface contract → to the user story it serves
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
3. **From Data Model**:
- Map each entity to the user story(ies) that need it
- If entity serves multiple stories: Put in earliest story or Setup phase
- Relationships → service layer tasks in appropriate story phase
4. **From Setup/Infrastructure**:
- Shared infrastructure → Setup phase (Phase 1)
- Foundational/blocking tasks → Foundational phase (Phase 2)
- Story-specific setup → within that story's phase
### Phase Structure
- **Phase 1**: Setup (project initialization)
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
- Each phase should be a complete, independently testable increment
- **Final Phase**: Polish & Cross-Cutting Concerns
## Done When
- [ ] tasks.md generated with all phases, task IDs, and file paths
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with task count, story breakdown, and MVP scope
@@ -0,0 +1,112 @@
---
name: "speckit-taskstoissues"
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
argument-hint: "Optional filter or label for GitHub issues"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/taskstoissues.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before tasks-to-issues conversion)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
1. From the executed script, extract the path to **tasks**.
1. Get the Git remote by running:
```bash
git config --get remote.origin.url
```
> [!CAUTION]
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
- Only create issues for tasks that do not yet have a matching issue.
> [!CAUTION]
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
## Post-Execution Checks
**Check for extension hooks (after tasks-to-issues conversion)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+12 -2
View File
@@ -9,13 +9,23 @@ POSTGRES_HOST=postgres
POSTGRES_DB=support_dev
POSTGRES_USER=support_user
POSTGRES_PASSWORD=z1F3tKF1JNDBQmMq95Up
DATABASE_URL=postgresql://support_user:z1F3tKF1JNDBQmMq95Up@postgres:5432/support_dev
# DATABASE_URL=postgresql://support_user:z1F3tKF1JNDBQmMq95Up@postgres:5432/support_dev
DATABASE_URL=postgresql://support_user:SupportDev123@localhost:5432/support_dev
# Redis
REDIS_HOST=redis
# REDIS_HOST=redis
# REDIS_PORT=6379
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=D7FJ7QDKo5gF9KQAO1GL
# Security & CORS
# JWT_SECRET=super-secret-development-jwt-key-32-chars-long
# CORS_ORIGINS=https://support-dev.maskantech.in
# Security & CORS
JWT_SECRET=super-secret-development-jwt-key-32-chars-long
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=c2e444fe8cc19eb7465e2f8a05f7384628de7a879fcc812766e093c9182fcd58
CORS_ORIGINS=https://support-dev.maskantech.in
+43
View File
@@ -0,0 +1,43 @@
NODE_ENV=development
PORT=4501
# Build
BUILD_COMMAND=npm run build:development
# Database
POSTGRES_HOST=postgres
POSTGRES_DB=support_dev
POSTGRES_USER=support_user
POSTGRES_PASSWORD=CHANGE_ME
DATABASE_URL=postgresql://support_user:CHANGE_ME@postgres:5432/support_dev
# Redis
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
# Security & CORS
JWT_SECRET=CHANGE_ME_32_CHAR_MINIMUM_SECRET
JWT_ACCESS_EXPIRES=15m
JWT_REFRESH_EXPIRES=7d
# 64 hex chars (32 bytes) — generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INTEGRATION_CREDENTIAL_ENCRYPTION_KEY=CHANGE_ME_64_HEX_CHARACTERS
CORS_ORIGINS=http://localhost:3000
# AWS S3 / storage (MinIO locally — see docker-compose.development.yml)
AWS_REGION=us-east-1
AWS_S3_BUCKET=supporthub-attachments
AWS_ACCESS_KEY_ID=CHANGE_ME
AWS_SECRET_ACCESS_KEY=CHANGE_ME
AWS_S3_ENDPOINT=http://minio:9000
# AI Support — real Anthropic Claude integration (specs/005-ai-support). A real key is required
# for the AI support feature to function; the app boots without one, but every AI session errors.
ANTHROPIC_API_KEY=CHANGE_ME
AI_SUPPORT_MODEL=claude-opus-5
AI_SUPPORT_EFFORT=medium
AI_SUPPORT_DEFAULT_HIGH_CONFIDENCE=0.75
AI_SUPPORT_DEFAULT_LOW_CONFIDENCE=0.4
AI_SUPPORT_DEFAULT_MAX_CLARIFYING_QUESTIONS=2
AI_SUPPORT_MAX_REASONING_ITERATIONS_PER_TURN=4
-20
View File
@@ -1,20 +0,0 @@
NODE_ENV=production
PORT=4503
# Nest build
BUILD_COMMAND=npm run build:prod
# Database
POSTGRES_HOST=postgres
POSTGRES_DB=myapp_prod
POSTGRES_USER=myapp_prod
POSTGRES_PASSWORD=CHANGE_ME
DATABASE_URL=postgresql://myapp_prod:CHANGE_ME@postgres:5432/myapp_prod
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME
# Security & CORS
JWT_SECRET=CHANGE_ME_PRODUCTION_JWT_SECRET_32_CHARS
CORS_ORIGINS=https://app.supporthub.com,https://admin.supporthub.com
+8
View File
@@ -38,3 +38,11 @@ Thumbs.db
docker/postgres/data/
docker/redis/data/
docker/minio/data/
# Environment files (secrets) — never commit real credentials
.env
.env.*
!.env.example
# Load-test run reports — measurement artifacts, not fixtures (016-load-concurrency-testing)
tests/load/reports/
+9
View File
@@ -0,0 +1,9 @@
# Machine-local Spec Kit state — not meant to be shared.
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
# Local pointer to the current feature directory. Rewritten every time you
# switch features, so it is per-checkout state rather than something to share.
feature.json
# Per-machine extension config overrides.
extensions/*/local-config.yml
+9
View File
@@ -0,0 +1,9 @@
{
"ai": "claude",
"ai_skills": true,
"feature_numbering": "sequential",
"here": true,
"integration": "claude",
"script": "ps",
"speckit_version": "0.16.4"
}
+15
View File
@@ -0,0 +1,15 @@
{
"version": "0.16.4",
"integration_state_schema": 1,
"installed_integrations": [
"claude"
],
"integration_settings": {
"claude": {
"script": "ps",
"invoke_separator": "-"
}
},
"integration": "claude",
"default_integration": "claude"
}
@@ -0,0 +1,17 @@
{
"integration": "claude",
"version": "0.16.4",
"installed_at": "2026-08-21T10:25:12.771236+00:00",
"files": {
".claude/skills/speckit-analyze/SKILL.md": "5d0565394ce8a573476718e546df3561357fd89061e9608c26fe97176e3660f4",
".claude/skills/speckit-clarify/SKILL.md": "122da9a8c710df930fbe8219c3feb33ffd610f9659b83574e1dffb98bf5e1bd4",
".claude/skills/speckit-constitution/SKILL.md": "78ed5639ada6bafffba4d7def4e3fbf36eb4412edb5fe45664fcea52726eb37a",
".claude/skills/speckit-implement/SKILL.md": "00a8aeb8aa4038ad7ccdee7b21e15dd473f1aa100d022ae1d04f0939c643bc96",
".claude/skills/speckit-converge/SKILL.md": "ca224eb399ff835884787dc87aaf862930f54bc44f8b1ad9dcc9eb67962a9e1d",
".claude/skills/speckit-plan/SKILL.md": "99ee3d64df52b575933123a3491d43c8820d02914e54f98a2ef09ff456257e03",
".claude/skills/speckit-checklist/SKILL.md": "7c38cd20eae8841226e053a46b6be7e30550a83520d865075c38168bfcef6412",
".claude/skills/speckit-specify/SKILL.md": "42fe016b9183bb8fa7ce7c65e04ea8d382f7f2abfc94849aeead999247675886",
".claude/skills/speckit-tasks/SKILL.md": "2d409fd3edb0bb0b97913168b3f2fd9bbfb327bff31a8bf1ed1a737a446889ca",
".claude/skills/speckit-taskstoissues/SKILL.md": "613f41db8bd472a895b47a3a7051f836e77425d11e23ff72e92ee043225dcd98"
}
}
@@ -0,0 +1,19 @@
{
"integration": "speckit",
"version": "0.16.4",
"installed_at": "2026-08-21T10:25:14.312862+00:00",
"files": {
".specify/scripts/powershell/check-prerequisites.ps1": "c2586898d293c92f0839ef338b7a005c7d9a9d71a5f9e267ed7e01208a66baaf",
".specify/scripts/powershell/common.ps1": "69c2bc6c40455a268c02d53ca4c8ac5f2e2df98f05293ea05245b0d462040bda",
".specify/scripts/powershell/create-new-feature.ps1": "c6d5e64455635bc9d19e2ec902de2f72a7f834afd8b47a1a3d6323f7ed0cbb62",
".specify/scripts/powershell/resolve-template.ps1": "e49c565a09902e4ebd4b5a51c4e014d5067fdee5b1592f15cb44ef31f430d745",
".specify/scripts/powershell/setup-plan.ps1": "089362994a002bb91d9b93daea2dc21676119839d700d79e7b69f4a72e623ed1",
".specify/scripts/powershell/setup-tasks.ps1": "c83d843c1640dca75fdac922a95cd97d8612d49bfe8331ee44390e85fe434a19",
".specify/templates/checklist-template.md": "856532b3cb66171c662cc16f16b31a5856e4655a8666aad1e545bbfc7f603ca1",
".specify/templates/constitution-template.md": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
".specify/templates/plan-template.md": "7e637502d41eccf0ca672496636365691fdca62ef37b27ec07fcb412dbfa90d4",
".specify/templates/spec-template.md": "3945437fc35cd30a5b2bf7beea680337c3516826d3efa5a6b92c4a7eca1ba28e",
".specify/templates/tasks-template.md": "fc29a233f6f5a27ca31f1aa46b596af6500c627441c6e62b2bc4a1d721525842",
".specify/.gitignore": "8c908410d177a1ef3d0dee16d7ad55f2ac3333df3104c4d4adee1c9b82f1dbc1"
}
}
@@ -0,0 +1,4 @@
{
"sha256": "ce7549540fa45543cca797a150201d868e64495fdff39dc38246fb17bd4024b3",
"source": "core"
}
+142
View File
@@ -0,0 +1,142 @@
<!--
Sync Impact Report
Version change: [TEMPLATE] → 1.0.0 (initial ratification)
Modified principles: n/a (first concrete version; template placeholders replaced)
Added sections:
- Core Principles IVIII (SaaS identity authority, configuration over hardcoding,
layered architecture/module boundaries, AI-recommends/policy-decides, evidence-based
verification, durable audit & history, concurrency-safe job handling, ticket/problem
separation)
- Technology & Platform Constraints
- Testing, Observability & CI/CD Gates
- Governance
Removed sections: none (placeholders only)
Deferred items:
- TODO(RATIFICATION_DATE): original adoption date predates this codified constitution
and was not recorded; using first-codification date as a placeholder until the team
confirms the true ratification date.
Templates requiring follow-up: none checked yet — run speckit-specify/plan/tasks next
and verify they don't reference stale template placeholder names.
-->
# SupportHub Constitution
## Core Principles
### I. SaaS Is the Sole Identity & Access Authority (NON-NEGOTIABLE)
SupportHub MUST NOT duplicate, shadow, or re-implement SaaS user identity, tenant identity,
product access, subscriptions, permissions, or authentication. It integrates with the owning
SaaS via secure APIs and per-product integration credentials, and stores only external
references (`externalUserId`, `externalTenantId`, `externalProductId`). SupportHub is the
sole authority only for its own domain: tickets, problems, support org structure, routing/
assignment, SLA, escalation, investigation/root cause/solution/verification/resolution,
knowledge, and support audit. Rationale: a second, drifting identity/RBAC system is worse
than no system — it creates authorization ambiguity. Exactly one source of truth per concern
keeps that ambiguity from existing.
### II. Configuration Over Hardcoding
Support hierarchy, SLA values, escalation paths, routing rules, and assignment strategy MUST
be admin-configurable data, never hardcoded in application code. Anything the business can
legitimately change without a deploy MUST be driven by configuration or persisted state, not
by editing source. Rationale: these policies change on business cadence, not engineering
cadence; hardcoding them forces a deploy for every policy tweak and makes non-engineers
dependent on engineering for routine changes.
### III. Layered Architecture With Enforced Module Boundaries
Every module follows Route → Schema validation → Controller → Service → (Engine/Rules if
required) → Repository → Prisma → PostgreSQL. Controllers MUST NOT touch Prisma or contain
business logic; routes MUST NOT contain business logic; the repository is the only layer
permitted to call Prisma. A module's internals are reachable only through its own public
`index.ts` — no deep cross-module imports, no circular module dependencies, no giant global
services. Rationale: this is what keeps a modular monolith splittable later without a rewrite,
and what makes module boundaries reviewable rather than aspirational.
### IV. AI Recommends, Deterministic Policy Decides
The AI Support Agent MUST NOT receive unrestricted backend access, MUST NOT invent
troubleshooting steps or product behavior beyond retrieved knowledge, and MUST NOT execute a
high-risk action without an explicit permission/policy check — regardless of the model's
reported confidence. Tool execution is scoped and enforced by deterministic policy code, never
left to model judgment alone. Rationale: the LLM proposes a diagnosis or action; policy code is
the actual authority. This is what makes running AI-first support against production systems
safe.
### V. Evidence-Based Verification
A problem MUST NOT be marked resolved on the customer's say-so alone wherever a system signal
is available to check the outcome. Recording a resolution requires verification evidence
attached to the ticket/problem, not just a customer confirmation click. Rationale: customer
"yes, it's fixed" clicks on problems that recur erode trust in both the AI and human resolution
paths; evidence is what separates a genuinely closed loop from a hopeful one.
### VI. Durable Audit & History
All important operations — assignment, escalation, SLA transitions, resolution, AI tool calls —
MUST be audit-logged. Full AI session history and full resolution history MUST be preserved,
never overwritten or summarized away. Every log line MUST carry a request ID/correlation ID so
a single ticket's full journey (AI session → tool calls → escalation → assignment → SLA events)
is traceable end to end. Rationale: admin trust, dispute resolution, and debugging all depend on
nothing about a ticket's journey being silently lost.
### VII. Concurrency-Safe, Durable Job Handling
SLA enforcement MUST NOT rely on in-memory timers (e.g. `setTimeout`) — enforcement state MUST
survive a process restart. Assignment and escalation logic MUST be tested under concurrency
(e.g. two tickets assigned simultaneously must never double-assign or corrupt round-robin
state), and job handlers MUST be idempotent (a rule firing twice must not create duplicate
events). Large files (attachments) MUST NOT be stored in PostgreSQL — use object storage.
Rationale: assignment and SLA are correctness-critical under real concurrent load; treating them
as single-threaded conveniences is how double-assignment and missed SLA breaches happen in
production.
### VIII. Problem and Ticket Are Separate, Related Entities
"Ticket" (the durable, customer-facing record created immediately when a problem is reported)
and "Problem" (the thing being diagnosed and investigated) MUST remain distinct, related
entities — never collapsed into one model. Rationale: a ticket exists before diagnosis begins
and can outlive multiple problem/investigation cycles; merging the two loses that lifecycle
distinction and makes the AI-first flow (ticket created at `NEW`, before AI even starts) harder
to represent correctly.
## Technology & Platform Constraints
- Stack: Node.js + TypeScript, Fastify, PostgreSQL + Prisma, Redis + BullMQ, Pino (structured
logging), OpenAPI, Zod (validation), Vitest, Docker.
- Architecture style: modular monolith. Do not decompose into microservices prematurely —
the module boundaries required by Principle III exist to make a future split *possible*,
not to justify doing one now.
- Standard module shape: `controller/ routes/ schema/ repository/ service/ types/ mapper/
constants/ index.ts`. Modules with real decision logic (not just CRUD) additionally use
`engine/ rules/ strategies/ calculators/`.
## Testing, Observability & CI/CD Gates
- Required backend test categories: unit, integration, E2E, concurrency (assignment races),
SLA (pause/resume correctness, business-calendar math, durability across a simulated process
restart), escalation idempotency, orchestration (capability matching, hierarchy traversal,
strategy selection), and AI tool-permission tests (the AI must never invoke a tool it isn't
scoped for; high-risk tools require policy/approval regardless of AI confidence).
- Two critical end-to-end scenarios MUST exist as automated tests at all times: (A) AI resolves
directly — problem → knowledge → guided troubleshooting → verification → AI-resolved; (B) AI
escalates to human — problem → failed AI troubleshooting → escalation → orchestration →
assignment → SLA → investigation → solution → verification → resolution → closure.
- Observability is wired in from the start, not retrofitted: Pino structured logs with a
request ID and correlation ID on every line; `GET /health`, `GET /health/live`,
`GET /health/ready`, `GET /metrics` exposed from day one.
- CI (Jenkins) MUST run, in order: checkout → install → environment validation → typecheck →
lint → format check → unit test → integration test → E2E test → build → Docker build →
publish → deploy. Production deployments use protected Jenkins-managed credentials; real
secrets are never committed to the repository.
## Governance
This constitution supersedes ad hoc conventions and undocumented team habits. All PRs and code
reviews MUST verify compliance with the principles above before merge.
Amendments require: a documented rationale for the change, a version bump under the semantic
versioning rule below, and an updated Sync Impact Report prepended to this file. MAJOR = a
backward-incompatible principle removal or redefinition. MINOR = a new principle added, or
existing guidance materially expanded. PATCH = clarification, wording, or typo fixes with no
semantic change. Any exception to a MUST/MUST NOT rule requires explicit written justification
in the relevant PR description and is expected to be rare, not routine.
Detailed product and system design lives in `docs/00-INDEX.md` through
`docs/10-implementation-roadmap.md` — this constitution states the non-negotiable engineering
rules; the docs explain the full system those rules protect.
**Version**: 1.0.0 | **Ratified**: TODO(RATIFICATION_DATE): confirm original adoption date | **Last Amended**: 2026-08-21
@@ -0,0 +1,174 @@
#!/usr/bin/env pwsh
# Consolidated prerequisite checking script (PowerShell)
#
# This script provides unified prerequisite checking for Spec-Driven Development workflow.
# It replaces the functionality previously spread across multiple scripts.
#
# Usage: ./check-prerequisites.ps1 [OPTIONS]
#
# OPTIONS:
# -Json Output in JSON format
# -RequireTasks Require tasks.md to exist (for implementation phase)
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
# -PathsOnly Only output path variables (no validation)
# -Template NAME Include composed template content in JSON output
# -Help, -h Show help message
[CmdletBinding()]
param(
[switch]$Json,
[switch]$RequireTasks,
[switch]$IncludeTasks,
[switch]$PathsOnly,
[string]$Template,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output @"
Usage: check-prerequisites.ps1 [OPTIONS]
Consolidated prerequisite checking for Spec-Driven Development workflow.
OPTIONS:
-Json Output in JSON format
-RequireTasks Require tasks.md to exist (for implementation phase)
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
-PathsOnly Only output path variables (no prerequisite validation)
-Template NAME Include composed template content in JSON output
-Help, -h Show this help message
EXAMPLES:
# Check task prerequisites (plan.md required)
.\check-prerequisites.ps1 -Json
# Check implementation prerequisites (plan.md + tasks.md required)
.\check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks
# Get feature paths only (no validation)
.\check-prerequisites.ps1 -PathsOnly
"@
exit 0
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths.
# In -PathsOnly mode this is pure resolution, so pass -NoPersist to opt out of
# the feature.json write side effect (issue #3025).
if ($PathsOnly) {
$paths = Get-FeaturePathsEnv -NoPersist
} else {
$paths = Get-FeaturePathsEnv
}
# If paths-only mode, output paths and exit (no validation)
if ($PathsOnly) {
if ($Json) {
[PSCustomObject]@{
REPO_ROOT = $paths.REPO_ROOT
BRANCH = $paths.CURRENT_BRANCH
FEATURE_DIR = $paths.FEATURE_DIR
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
TASKS = $paths.TASKS
} | ConvertTo-Json -Compress
} else {
Write-Output "REPO_ROOT: $($paths.REPO_ROOT)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "TASKS: $($paths.TASKS)"
}
exit 0
}
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
$specifyCommand = '/speckit-specify'
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = '/speckit-plan'
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
$tasksCommand = '/speckit-tasks'
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
exit 1
}
# Build list of available documents
$docs = @()
# Always check these optional docs
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
# Check contracts directory (only if it exists and has files)
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Include tasks.md if requested and it exists
if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
$docs += 'tasks.md'
}
$templateContent = $null
if ($Template) {
$templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT
if ($null -eq $templateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)")
exit 1
}
}
# Output results
if ($Json) {
# JSON output
$result = [ordered]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
}
if ($Template) {
$result.TEMPLATE_CONTENT = $templateContent
}
[PSCustomObject]$result | ConvertTo-Json -Compress
} else {
# Text output
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
Write-Output "AVAILABLE_DOCS:"
# Show status of each potential document.
# These helpers report their line with Write-Output and ALSO return a
# bool, both on the Success stream, so 'Out-Null' discarded the report
# line along with the return value and left AVAILABLE_DOCS empty. Drop
# only the boolean so the per-document lines reach stdout like the
# bash and Python twins.
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Where-Object { $_ -isnot [bool] }
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Where-Object { $_ -isnot [bool] }
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Where-Object { $_ -isnot [bool] }
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Where-Object { $_ -isnot [bool] }
if ($IncludeTasks) {
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Where-Object { $_ -isnot [bool] }
}
}
+796
View File
@@ -0,0 +1,796 @@
#!/usr/bin/env pwsh
# Common PowerShell functions analogous to common.sh
# Find repository root by searching upward for .specify directory
# This is the primary marker for spec-kit projects
function Find-SpecifyRoot {
param([string]$StartDir = (Get-Location).Path)
# Normalize to absolute path to prevent issues with relative paths
# Use -LiteralPath to handle paths with wildcard characters ([, ], *, ?)
$resolved = Resolve-Path -LiteralPath $StartDir -ErrorAction SilentlyContinue
$current = if ($resolved) { $resolved.Path } else { $null }
if (-not $current) { return $null }
while ($true) {
if (Test-Path -LiteralPath (Join-Path $current ".specify") -PathType Container) {
return $current
}
$parent = Split-Path $current -Parent
if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) {
return $null
}
$current = $parent
}
}
# Resolve an explicit SPECIFY_INIT_DIR project override (the directory that
# *contains* .specify/), for non-interactive / CI use -- e.g. running a Spec Kit
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
# design: the path must exist and
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# This is the single resolver: bundled extensions inherit it by sourcing core
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
function Resolve-SpecifyInitDir {
param([switch]$ReturnNullOnError)
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
$initDir = Join-Path (Get-Location).Path $initDir
}
$resolved = Resolve-Path -LiteralPath $initDir -ErrorAction SilentlyContinue
# Resolve-Path also succeeds for files, so check the resolved path is a
# directory; otherwise a file value would slip through to the less accurate
# "not a Spec Kit project" error below.
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
if ($ReturnNullOnError) { return $null }
exit 1
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
# the returned root matches the bash resolver, whose `cd && pwd` never yields
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
# become 'C:', which every later API re-resolves against the current
# directory instead of the drive root). No-op on a path with no trailing
# separator.
$initRoot = $resolved.Path.TrimEnd('/', '\')
if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) {
$initRoot = $resolved.Path
}
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
if ($ReturnNullOnError) { return $null }
exit 1
}
return $initRoot
}
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
param([switch]$ReturnNullOnError)
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ($env:SPECIFY_INIT_DIR) {
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
}
# First, look for .specify directory (spec-kit's own marker)
$specifyRoot = Find-SpecifyRoot
if ($specifyRoot) {
return $specifyRoot
}
# Final fallback to script location
# Use -LiteralPath to handle paths with wildcard characters
return (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "../../..")).Path
}
function Get-CurrentBranch {
# Return feature name from explicit state only.
# Feature state is set by SPECIFY_FEATURE (from create-new-feature or
# the git extension) or implicitly via .specify/feature.json.
if ($env:SPECIFY_FEATURE) {
return $env:SPECIFY_FEATURE
}
# No explicit feature set - return empty to signal "unknown".
return ""
}
# Persist a feature_directory value to .specify/feature.json.
# Writes only when the file is missing or the value differs from what's stored.
function Save-FeatureJson {
param(
[Parameter(Mandatory = $true)][string]$RepoRoot,
[Parameter(Mandatory = $true)][string]$FeatureDirectory
)
# Strip repo root prefix if the value is absolute and under repo root.
# Use case-insensitive comparison on Windows only (case-sensitive filesystems elsewhere).
$prefix = $RepoRoot + [System.IO.Path]::DirectorySeparatorChar
if ($null -ne $IsWindows) { $onWin = $IsWindows } else { $onWin = $true }
if ($onWin) {
$cmp = [System.StringComparison]::OrdinalIgnoreCase
} else {
$cmp = [System.StringComparison]::Ordinal
}
if ($FeatureDirectory.StartsWith($prefix, $cmp)) {
$FeatureDirectory = $FeatureDirectory.Substring($prefix.Length)
}
$fjPath = Join-Path (Join-Path $RepoRoot '.specify') 'feature.json'
# Read current value and skip write when unchanged
if (Test-Path -LiteralPath $fjPath -PathType Leaf) {
try {
$raw = Get-Content -LiteralPath $fjPath -Raw
$cfg = $raw | ConvertFrom-Json
if ($cfg.feature_directory -eq $FeatureDirectory) {
return
}
} catch {
# File is corrupt or unreadable - overwrite it
}
}
# Ensure .specify/ directory exists
$specifyDir = Join-Path $RepoRoot '.specify'
if (-not (Test-Path -LiteralPath $specifyDir -PathType Container)) {
New-Item -ItemType Directory -Path $specifyDir -Force | Out-Null
}
# Write feature.json
$json = @{ feature_directory = $FeatureDirectory } | ConvertTo-Json -Compress
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($fjPath, $json, $utf8NoBom)
}
function Get-FeaturePathsEnv {
# Read-only callers (e.g. check-prerequisites.ps1 -PathsOnly) pass -NoPersist
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param(
[switch]$NoPersist,
[switch]$ReturnNullOnError
)
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
if (-not $repoRoot) { return $null }
$currentBranch = Get-CurrentBranch
# Resolve feature directory. Priority:
# 1. SPECIFY_FEATURE_DIRECTORY env var (explicit override)
# 2. .specify/feature.json "feature_directory" key (persisted by specify command)
# 3. Error - no feature context available
$featureJson = Join-Path $repoRoot '.specify/feature.json'
if ($env:SPECIFY_FEATURE_DIRECTORY) {
$featureDir = $env:SPECIFY_FEATURE_DIRECTORY
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
# Persist to feature.json so future sessions without the env var still
# work - unless the caller opted out for read-only resolution (#3025).
if (-not $NoPersist) {
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $env:SPECIFY_FEATURE_DIRECTORY
}
} elseif (Test-Path $featureJson) {
$featureJsonRaw = Get-Content -LiteralPath $featureJson -Raw
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
if ($featureConfig.feature_directory) {
$featureDir = $featureConfig.feature_directory
# Normalize relative paths to absolute under repo root
if (-not [System.IO.Path]::IsPathRooted($featureDir)) {
$featureDir = Join-Path $repoRoot $featureDir
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
if ($ReturnNullOnError) { return $null }
exit 1
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
if ($ReturnNullOnError) { return $null }
exit 1
}
# When no branch context exists (no SPECIFY_FEATURE, feature resolved via
# SPECIFY_FEATURE_DIRECTORY or feature.json), fall back to the feature
# directory basename so CURRENT_BRANCH is a usable identifier rather than
# an empty, misleading value (issue #3026).
if (-not $currentBranch) {
# TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework.
$featureDirTrimmed = $featureDir.TrimEnd('/', '\')
$currentBranch = Split-Path -Leaf $featureDirTrimmed
}
[PSCustomObject]@{
REPO_ROOT = $repoRoot
CURRENT_BRANCH = $currentBranch
FEATURE_DIR = $featureDir
FEATURE_SPEC = Join-Path $featureDir 'spec.md'
IMPL_PLAN = Join-Path $featureDir 'plan.md'
TASKS = Join-Path $featureDir 'tasks.md'
RESEARCH = Join-Path $featureDir 'research.md'
DATA_MODEL = Join-Path $featureDir 'data-model.md'
QUICKSTART = Join-Path $featureDir 'quickstart.md'
CONTRACTS_DIR = Join-Path $featureDir 'contracts'
}
}
function Test-FileExists {
param([string]$Path, [string]$Description)
if (Test-Path -Path $Path -PathType Leaf) {
Write-Output " [OK] $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
return $false
}
}
function Test-DirHasFiles {
param([string]$Path, [string]$Description)
# A directory counts as non-empty when Get-ChildItem returns any entry
# (files or subdirectories) -- matching the JSON contracts checks in
# check-prerequisites.ps1 / setup-tasks.ps1, and treating a directory whose
# only contents are subdirectories (e.g. contracts/v1/openapi.yaml) as
# non-empty like bash check_dir. Filtering out subdirectories would
# mis-report such a directory as empty.
if ((Test-Path -Path $Path -PathType Container) -and (Get-ChildItem -Path $Path -ErrorAction SilentlyContinue | Select-Object -First 1)) {
Write-Output " [OK] $Description"
return $true
} else {
Write-Output " [FAIL] $Description"
return $false
}
}
function Get-InvokeSeparator {
param([string]$RepoRoot = (Get-RepoRoot))
if ($null -eq $script:SpecKitInvokeSeparatorCache) {
$script:SpecKitInvokeSeparatorCache = @{}
}
if ($script:SpecKitInvokeSeparatorCache.ContainsKey($RepoRoot)) {
return $script:SpecKitInvokeSeparatorCache[$RepoRoot]
}
$separator = '.'
$integrationJson = Join-Path $RepoRoot '.specify/integration.json'
if (Test-Path -LiteralPath $integrationJson -PathType Leaf) {
try {
$state = Get-Content -LiteralPath $integrationJson -Raw | ConvertFrom-Json
$key = if ($state.default_integration) { [string]$state.default_integration } elseif ($state.integration) { [string]$state.integration } else { '' }
if ($key -and $state.integration_settings) {
$settingProperty = $state.integration_settings.PSObject.Properties[$key]
if ($settingProperty) {
$setting = $settingProperty.Value
if ($setting -and ($setting.invoke_separator -eq '.' -or $setting.invoke_separator -eq '-')) {
$separator = [string]$setting.invoke_separator
}
}
}
} catch {
$separator = '.'
}
}
$script:SpecKitInvokeSeparatorCache[$RepoRoot] = $separator
return $separator
}
function Format-SpecKitCommand {
param(
[Parameter(Mandatory = $true)][string]$CommandName,
[string]$RepoRoot = (Get-RepoRoot)
)
$separator = Get-InvokeSeparator -RepoRoot $RepoRoot
$name = $CommandName.TrimStart('/')
if ($name.StartsWith('speckit.')) {
$name = $name.Substring(8)
} elseif ($name.StartsWith('speckit-')) {
$name = $name.Substring(8)
}
$name = $name -replace '\.', $separator
return "/speckit$separator$name"
}
# Find a usable Python 3 executable (python3, python, or py -3).
# Returns the command/arguments as an array, or $null if none found.
function Get-Python3Command {
if (Get-Command python3 -ErrorAction SilentlyContinue) { return @('python3') }
if (Get-Command python -ErrorAction SilentlyContinue) {
$ver = & python --version 2>&1
if ($ver -match 'Python 3') { return @('python') }
}
if (Get-Command py -ErrorAction SilentlyContinue) {
$ver = & py -3 --version 2>&1
if ($ver -match 'Python 3') { return @('py', '-3') }
}
return $null
}
function Get-NormalizedPriority {
param($Value)
if ($Value -is [bool]) { return 10 }
if ($Value -is [string]) {
$integerText = $Value.Trim()
if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 }
$Value = $integerText.Replace('_', '')
}
try {
$parsedPriority = [System.Numerics.BigInteger]$Value
} catch {
return 10
}
return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 })
}
function Get-SortedExtensionIds {
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)
$registeredNames = @()
$ranked = @()
$registryFile = Join-Path $ExtensionsDir '.registry'
# Detect any filesystem entry at the registry path without following symlinks.
# Test-Path follows links and reports $false for a dangling symlink, so a
# broken .registry symlink would otherwise bypass this guard and let the
# directory scan below enable every on-disk extension. Enumerating the parent
# directory still observes a broken symlink as an entry.
$registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue |
Where-Object { $_.Name -eq '.registry' } |
Select-Object -First 1
if ($registryEntry) {
if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) {
throw "Invalid extension registry ${registryFile}: not a regular file"
}
try {
$data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
} catch {
throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)"
}
if ($null -eq $data -or $data -isnot [PSCustomObject]) {
throw "Invalid extension registry ${registryFile}: root must be a mapping"
}
$extensionsProperty = $data.PSObject.Properties['extensions']
if ($extensionsProperty) {
if ($extensionsProperty.Value -isnot [PSCustomObject]) {
throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping"
}
$extensions = $extensionsProperty.Value
} else {
$extensions = [PSCustomObject]@{}
}
$registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name })
foreach ($entry in $extensions.PSObject.Properties) {
if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) {
continue
}
$enabledProperty = $entry.Value.PSObject.Properties['enabled']
if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue }
$priority = 10
$priorityProperty = $entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
$priority = Get-NormalizedPriority -Value $priorityProperty.Value
}
$ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name }
}
}
foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) {
if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) {
$ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name }
}
}
return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id }
}
# Resolve a template name to a file path using the priority stack:
# 1. .specify/templates/overrides/
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
# 3. .specify/extensions/<ext-id>/templates/
# 4. .specify/templates/ (core)
function Resolve-Template {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null }
$base = Join-Path $RepoRoot '.specify/templates'
# Priority 1: Project overrides
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) { return $override }
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
return Get-NormalizedPriority -Value $priorityProperty.Value
}
}
return 10
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object {
$enabled = $_.Value.PSObject.Properties['enabled']
-not $enabled -or [bool]$enabled.Value
} |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
$registryParsed = $false
}
}
if ($registryParsed) {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $presetsDir "$presetId/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
$candidate = Join-Path $preset.FullName "$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
}
}
# Priority 3: Extension-provided templates
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (Test-Path $extDir) {
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) { return $candidate }
}
}
# Priority 4: Core templates
$core = Join-Path $base "$TemplateName.md"
if (Test-Path $core) { return $core }
return $null
}
# Resolve a template name to composed content using composition strategies.
# Reads strategy metadata from preset manifests and composes content
# from multiple layers using prepend, append, or wrap strategies.
function Resolve-TemplateContent {
param(
[Parameter(Mandatory=$true)][string]$TemplateName,
[Parameter(Mandatory=$true)][string]$RepoRoot
)
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') {
return $null
}
$base = Join-Path $RepoRoot '.specify/templates'
# Collect all layers (highest priority first)
$layerPaths = @()
$layerStrategies = @()
# Priority 1: Project overrides (always "replace")
$override = Join-Path $base "overrides/$TemplateName.md"
if (Test-Path $override) {
return [System.IO.File]::ReadAllText(
$override,
[System.Text.Encoding]::UTF8
)
}
$effectiveBaseFound = $false
# Priority 2: Installed presets (sorted by priority from .registry)
$presetsDir = Join-Path $RepoRoot '.specify/presets'
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
$registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
throw 'Registry root must be an object'
}
$presetsProperty = $registryData.PSObject.Properties['presets']
if ($presetsProperty) {
$presets = $presetsProperty.Value
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
throw 'Registry presets must be an object'
}
$presetEntries = @($presets.PSObject.Properties)
$priorityFor = {
param($Entry)
if ($Entry.Value -is [PSCustomObject]) {
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
if ($priorityProperty) {
return Get-NormalizedPriority -Value $priorityProperty.Value
}
}
return 10
}
$sortedPresets = $presetEntries |
Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object {
$enabled = $_.Value.PSObject.Properties['enabled']
-not $enabled -or [bool]$enabled.Value
} |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
ForEach-Object { $_.Name }
}
$registryParsed = $true
} catch {
$registryParsed = $false
}
}
if (-not $registryParsed) {
$sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
Sort-Object Name |
ForEach-Object { $_.Name }
}
$pyCmd = @(Get-Python3Command)
foreach ($presetId in $sortedPresets) {
# Read strategy and file path from preset manifest
$strategy = 'replace'
$manifestFilePath = ''
$manifestDeclared = $false
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
if ((Test-Path $manifest) -and -not $pyCmd) {
throw "Python 3 and PyYAML are required to resolve preset template composition"
}
if (Test-Path $manifest) {
try {
# Use Python to parse YAML manifest for strategy and file path
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
$pyStderrFile = [System.IO.Path]::GetTempFileName()
$stratResult = & $pyCmd[0] @pyArgs -c @"
import sys
try:
import yaml
except ImportError:
print('yaml_missing', file=sys.stderr)
sys.exit(2)
try:
with open(sys.argv[1], encoding='utf-8') as f:
data = yaml.safe_load(f)
if not isinstance(data, dict):
raise ValueError('manifest root must be a mapping')
if 'provides' not in data:
raise ValueError('manifest missing provides section')
provides = data['provides']
if not isinstance(provides, dict):
raise ValueError('manifest provides must be a mapping')
if 'templates' not in provides:
raise ValueError('manifest provides missing templates')
templates = provides['templates']
if not isinstance(templates, list):
raise ValueError('manifest templates must be a list')
if not templates:
raise ValueError('manifest must provide at least one template')
valid_types = ('template', 'command', 'script')
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
for t in templates:
if not isinstance(t, dict):
raise ValueError('manifest template entries must be mappings')
if 'type' not in t or 'name' not in t or 'file' not in t:
raise ValueError('manifest template entry missing type, name, or file')
for field in ('type', 'name', 'file'):
if not isinstance(t[field], str):
raise ValueError('manifest template ' + field + ' must be a string')
if t['type'] not in valid_types:
raise ValueError('invalid manifest template type')
strategy = t.get('strategy', 'replace')
if not isinstance(strategy, str):
raise ValueError('manifest template strategy must be a string')
strategy = strategy.lower()
if strategy not in valid_strategies:
raise ValueError('invalid manifest template strategy')
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
raise ValueError('invalid manifest script strategy')
for t in templates:
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
file_value = t.get('file', '')
strategy = t.get('strategy', 'replace')
print('found\t' + strategy + '\t' + file_value)
sys.exit(0)
print('absent\treplace\t')
except Exception as exc:
print(f'manifest_invalid: {exc}', file=sys.stderr)
sys.exit(3)
"@ $manifest $TemplateName 2>$pyStderrFile
if ($LASTEXITCODE -ne 0) {
if ($LASTEXITCODE -eq 2) {
throw "PyYAML is required to resolve preset template composition"
}
throw "Invalid preset manifest $manifest"
}
if ($stratResult) {
$parts = $stratResult.Trim() -split "`t", 3
$manifestDeclared = $parts[0] -eq 'found'
$strategy = $parts[1].ToLowerInvariant()
if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] }
}
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
} catch {
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
throw
}
}
# Try manifest file path first, then convention path
$candidate = $null
if ($manifestFilePath) {
# Reject absolute paths and parent traversal
if ([System.IO.Path]::IsPathRooted($manifestFilePath) -or $manifestFilePath -match '\.\.[\\/]') {
$manifestFilePath = ''
}
}
if ($manifestFilePath) {
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
if (Test-Path $mf) { $candidate = $mf }
}
if (-not $candidate -and -not $manifestDeclared) {
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
if (-not $candidate) {
$cf = Join-Path $presetsDir "$presetId/$TemplateName.md"
if (Test-Path $cf) { $candidate = $cf }
}
}
if ($candidate) {
$layerPaths += $candidate
$layerStrategies += $strategy
if ($strategy -eq 'replace') {
$effectiveBaseFound = $true
break
}
}
}
}
# Priority 3: Extension-provided templates (always "replace")
$extDir = Join-Path $RepoRoot '.specify/extensions'
if (-not $effectiveBaseFound -and (Test-Path $extDir)) {
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
if (-not (Test-Path $candidate)) {
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
}
if (Test-Path $candidate) {
$layerPaths += $candidate
$layerStrategies += 'replace'
$effectiveBaseFound = $true
break
}
}
}
# Priority 4: Core templates (always "replace")
$core = Join-Path $base "$TemplateName.md"
if (-not $effectiveBaseFound -and (Test-Path $core)) {
$layerPaths += $core
$layerStrategies += 'replace'
}
if ($layerPaths.Count -eq 0) { return $null }
# If the top (highest-priority) layer is replace, it wins entirely --
# lower layers are irrelevant regardless of their strategies.
if ($layerStrategies[0] -eq 'replace') {
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
}
# Check if any layer uses a non-replace strategy
$hasComposition = $false
foreach ($s in $layerStrategies) {
if ($s -ne 'replace') { $hasComposition = $true; break }
}
if (-not $hasComposition) {
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
}
# Find the effective base: scan from highest priority (index 0) downward
# to find the nearest replace layer. Only compose layers above that base.
$baseIdx = -1
for ($i = 0; $i -lt $layerPaths.Count; $i++) {
if ($layerStrategies[$i] -eq 'replace') {
$baseIdx = $i
break
}
}
if ($baseIdx -lt 0) {
throw "Template '$TemplateName' has composing layers but no replace base"
}
$content = [System.IO.File]::ReadAllText(
$layerPaths[$baseIdx],
[System.Text.Encoding]::UTF8
)
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
$path = $layerPaths[$i]
$strat = $layerStrategies[$i]
$layerContent = [System.IO.File]::ReadAllText(
$path,
[System.Text.Encoding]::UTF8
)
switch ($strat) {
'replace' { $content = $layerContent }
'prepend' { $content = "$layerContent`n`n$content" }
'append' { $content = "$content`n`n$layerContent" }
'wrap' {
if (-not $layerContent.Contains('{CORE_TEMPLATE}')) {
throw "Wrap strategy missing {CORE_TEMPLATE} placeholder"
}
$content = $layerContent.Replace('{CORE_TEMPLATE}', $content)
}
default { throw "Unknown strategy: $strat" }
}
}
return $content
}
@@ -0,0 +1,319 @@
#!/usr/bin/env pwsh
# Create a new feature
[CmdletBinding()]
param(
[switch]$Json,
[switch]$AllowExistingBranch,
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
[string]$Number = '',
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
$maxBranchLength = 244
# Show help if requested
if ($Help) {
Write-Host "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
Write-Host ""
Write-Host "Options:"
Write-Host " -Json Output in JSON format"
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
Write-Host "Examples:"
Write-Host " ./create-new-feature.ps1 'Add user authentication system' -ShortName 'user-auth'"
Write-Host " ./create-new-feature.ps1 'Implement OAuth2 integration for API'"
Write-Host " ./create-new-feature.ps1 -Timestamp -ShortName 'user-auth' 'Add user authentication'"
exit 0
}
# Check if feature description provided
if (-not $FeatureDescription -or $FeatureDescription.Count -eq 0) {
Write-Error "Usage: ./create-new-feature.ps1 [-Json] [-DryRun] [-AllowExistingBranch] [-ShortName <name>] [-Number N] [-Timestamp] <feature description>"
exit 1
}
$featureDesc = ($FeatureDescription -join ' ').Trim()
# Validate description is not empty after trimming (e.g., user passed only whitespace)
if ([string]::IsNullOrWhiteSpace($featureDesc)) {
Write-Error "Error: Feature description cannot be empty or contain only whitespace"
exit 1
}
function Get-HighestNumberFromSpecs {
param([string]$SpecsDir)
[long]$highest = 0
if (Test-Path $SpecsDir) {
Get-ChildItem -Path $SpecsDir -Directory | ForEach-Object {
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if ($_.Name -match '^(\d{3,})-' -and $_.Name -notmatch '^\d{8}-\d{6}-') {
[long]$num = 0
if ([long]::TryParse($matches[1], [ref]$num) -and $num -gt $highest) {
$highest = $num
}
}
}
}
return $highest
}
function Test-SpecPrefixInUse {
param(
[string]$SpecsDir,
[string]$FeatureNum
)
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
return $false
}
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "$FeatureNum-*" } |
Select-Object -First 1)
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
function Get-FittedBranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
$fittedName = "$FeatureNum-$BranchSuffix"
if ($fittedName.Length -gt $maxBranchLength) {
$prefixLength = $FeatureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$fittedName = "$FeatureNum-$truncatedSuffix"
}
return $fittedName
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"
# Use common.ps1 functions which prioritize .specify
$repoRoot = Get-RepoRoot
Set-Location $repoRoot
$specsDir = Join-Path $repoRoot 'specs'
if (-not $DryRun) {
New-Item -ItemType Directory -Path $specsDir -Force | Out-Null
}
# Function to generate branch name with stop word filtering and length filtering
function Get-BranchName {
param([string]$Description)
# Common stop words to filter out
$stopWords = @(
'i', 'a', 'an', 'the', 'to', 'for', 'of', 'in', 'on', 'at', 'by', 'with', 'from',
'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had',
'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can', 'may', 'might', 'must', 'shall',
'this', 'that', 'these', 'those', 'my', 'your', 'our', 'their',
'want', 'need', 'add', 'get', 'set'
)
# Convert to lowercase and extract words (alphanumeric only)
$cleanName = $Description.ToLower() -replace '[^a-z0-9\s]', ' '
$words = $cleanName -split '\s+' | Where-Object { $_ }
# Filter words: remove stop words and words shorter than 3 chars (unless they're uppercase acronyms in original)
$meaningfulWords = @()
foreach ($word in $words) {
# Skip stop words
if ($stopWords -contains $word) { continue }
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Keep short words only if they appear as uppercase in original (likely
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
# bash script's case-sensitive grep; -match would be case-insensitive and
# would keep every short word.
$meaningfulWords += $word
}
}
# If we have meaningful words, use first 3-4 of them
if ($meaningfulWords.Count -gt 0) {
$maxWords = if ($meaningfulWords.Count -eq 4) { 4 } else { 3 }
$result = ($meaningfulWords | Select-Object -First $maxWords) -join '-'
return $result
} else {
# Fallback to original logic if no meaningful words found
$result = ConvertTo-CleanBranchName -Name $Description
$fallbackWords = ($result -split '-') | Where-Object { $_ } | Select-Object -First 3
return [string]::Join('-', $fallbackWords)
}
}
# Generate branch name
if ($ShortName) {
# Use provided short name, just clean it up
$branchSuffix = ConvertTo-CleanBranchName -Name $ShortName
} else {
# Generate from description with smart filtering
$branchSuffix = Get-BranchName -Description $featureDesc
}
# Treat an explicit empty string as omitted, matching the bash and Python twins.
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
# Warn if -Number and -Timestamp are both specified.
if ($Timestamp -and $hasNumber) {
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
$Number = ''
}
# Determine branch prefix
if ($Timestamp) {
$featureNum = Get-Date -Format 'yyyyMMdd-HHmmss'
$branchName = "$featureNum-$branchSuffix"
} else {
# Determine branch number from existing feature directories. Auto-detect only
# when -Number was not supplied; an explicit value (including 0) is honored,
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
[long]$resolvedNumber = 0
if (-not $hasNumber) {
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
if ($highestNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber = $highestNumber + 1
} elseif ($Number -notmatch '^[0-9]+$') {
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
exit 1
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
exit 1
}
$featureNum = ('{0:000}' -f $resolvedNumber)
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
$specConflict = $false
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$requestedDir = Join-Path $specsDir $requestedBranchName
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
}
}
if ($specConflict) {
$requestedNum = $featureNum
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
$resolvedNumber = $highestNumber
do {
if ($resolvedNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber++
$featureNum = ('{0:000}' -f $resolvedNumber)
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$originalBranchName = "$featureNum-$branchSuffix"
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
if ($branchName -ne $originalBranchName) {
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
}
$featureDir = Join-Path $specsDir $branchName
$specFile = Join-Path $featureDir 'spec.md'
if (-not $DryRun) {
if ((Test-Path -LiteralPath $featureDir -PathType Container) -and -not $AllowExistingBranch) {
if ($Timestamp) {
Write-Error "Error: Feature directory '$featureDir' already exists. Rerun to get a new timestamp or use a different -ShortName."
} else {
Write-Error "Error: Feature directory '$featureDir' already exists. Please use a different feature name or specify a different number with -Number."
}
exit 1
}
$needsSpec = -not (Test-Path -PathType Leaf $specFile)
$content = $null
if ($needsSpec) {
$content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot
}
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
if ($needsSpec) {
if ($null -ne $content) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
} else {
# Match the bash twin (create-new-feature.sh): warn on stderr that no
# spec template was found before creating an empty spec file, so the
# missing-template signal is not silently swallowed on Windows.
[Console]::Error.WriteLine("Warning: Spec template not found; created empty spec file")
New-Item -ItemType File -Path $specFile -Force | Out-Null
}
}
# Persist to .specify/feature.json so downstream commands can find the feature
Save-FeatureJson -RepoRoot $repoRoot -FeatureDirectory $featureDir
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
[Console]::Error.WriteLine("# To persist: $featureAssignment")
[Console]::Error.WriteLine("# $directoryAssignment")
}
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
SPEC_FILE = $specFile
FEATURE_NUM = $featureNum
}
if ($DryRun) {
$obj | Add-Member -NotePropertyName 'DRY_RUN' -NotePropertyValue $true
}
$obj | ConvertTo-Json -Compress
} else {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
Write-Output "# To persist in your shell: $featureAssignment"
Write-Output "# $directoryAssignment"
}
}
@@ -0,0 +1,38 @@
#!/usr/bin/env pwsh
param(
[Parameter(Position=0)]
[string]$TemplateName,
[switch]$Json,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Output "Usage: resolve-template.ps1 <template-name> [-Json]"
exit 0
}
if (-not $TemplateName) {
[Console]::Error.WriteLine("ERROR: Template name is required")
exit 1
}
. "$PSScriptRoot/common.ps1"
$repoRoot = Get-RepoRoot
$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot
if ($null -eq $templateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot")
exit 1
}
if ($Json) {
[PSCustomObject]@{
TEMPLATE_NAME = $TemplateName
TEMPLATE_CONTENT = $templateContent
} | ConvertTo-Json -Compress
} else {
[Console]::Out.Write($templateContent)
}
@@ -0,0 +1,83 @@
#!/usr/bin/env pwsh
# Setup implementation plan for a feature
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help,
# Capture extra positional arguments to match Bash/Python behavior.
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
# Show help if requested
if ($Help) {
Write-Output "Usage: ./setup-plan.ps1 [-Json] [-Help]"
Write-Output " -Json Output results in JSON format"
Write-Output " -Help Show this help message"
exit 0
}
# Load common functions
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
# Copy plan template if plan doesn't already exist
if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
if ($Json) {
[Console]::Error.WriteLine("Plan already exists at $($paths.IMPL_PLAN), skipping template copy")
} else {
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
}
} else {
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
if ($null -ne $content) {
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
# in -Json mode so stdout stays pure JSON, matching the sibling messages.
if ($Json) {
[Console]::Error.WriteLine("Copied plan template to $($paths.IMPL_PLAN)")
} else {
Write-Output "Copied plan template to $($paths.IMPL_PLAN)"
}
} else {
# Match the bash twin's wording and stream routing (stderr in -Json so
# stdout stays pure JSON, stdout otherwise), consistent with the sibling
# "Copied plan template" message above.
if ($Json) {
[Console]::Error.WriteLine("Warning: Plan template not found")
} else {
Write-Output "Warning: Plan template not found"
}
# Create a basic plan file if template doesn't exist
New-Item -ItemType File -Path $paths.IMPL_PLAN -Force | Out-Null
}
}
# Output results
if ($Json) {
$result = [PSCustomObject]@{
FEATURE_SPEC = $paths.FEATURE_SPEC
IMPL_PLAN = $paths.IMPL_PLAN
SPECS_DIR = $paths.FEATURE_DIR
BRANCH = $paths.CURRENT_BRANCH
}
$result | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_SPEC: $($paths.FEATURE_SPEC)"
Write-Output "IMPL_PLAN: $($paths.IMPL_PLAN)"
Write-Output "SPECS_DIR: $($paths.FEATURE_DIR)"
Write-Output "BRANCH: $($paths.CURRENT_BRANCH)"
}
@@ -0,0 +1,88 @@
#!/usr/bin/env pwsh
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help,
[Parameter(ValueFromRemainingArguments = $true)]
[string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
# Help wins over unknown-argument validation to match the Bash/Python
# variants, which stop at --help and exit 0.
if ($Help) {
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
exit 0
}
if ($RemainingArgs.Count -gt 0) {
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
exit 1
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
$paths = Get-FeaturePathsEnv -ReturnNullOnError
if (-not $paths) {
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
$planCommand = '/speckit-plan'
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
exit 1
}
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
$specifyCommand = '/speckit-specify'
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
exit 1
}
# Build available docs list
$docs = @()
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if ($null -eq $tasksTemplateContent) {
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
} else {
$tasksTemplate = ''
}
# Output results
if ($Json) {
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
TASKS_TEMPLATE = $tasksTemplate
TASKS_TEMPLATE_CONTENT = $tasksTemplateContent
} | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
Write-Output "AVAILABLE_DOCS:"
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
}
+45
View File
@@ -0,0 +1,45 @@
# [CHECKLIST TYPE] Checklist: [FEATURE NAME]
**Purpose**: [Brief description of what this checklist covers]
**Created**: [DATE]
**Feature**: [Link to spec.md or relevant documentation]
**Note**: This custom checklist is generated by the `/speckit-checklist` command based on feature context and requirements.
**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied.
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete.
<!--
============================================================================
IMPORTANT: The checklist items below are SAMPLE ITEMS for illustration only.
The /speckit-checklist command MUST replace these with actual items based on:
- User's specific checklist request
- Feature requirements from spec.md
- Technical context from plan.md
- Implementation details from tasks.md
DO NOT keep these sample items in the generated checklist file.
============================================================================
-->
## [Category 1]
- [ ] CHK001 First checklist item with clear action
- [ ] CHK002 Second checklist item
- [ ] CHK003 Third checklist item
## [Category 2]
- [ ] CHK004 Another category item
- [ ] CHK005 Item with specific criteria
- [ ] CHK006 Final item in this category
## Notes
- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
- `/speckit-implement` reads checklist checkbox state as a gate and must not modify markers
- `checklists/requirements.md` has a separate built-in lifecycle maintained by `/speckit-specify` and `/speckit-clarify`
- Add comments or findings inline
- Link to relevant resources or documentation
- Items are numbered sequentially for easy reference
@@ -0,0 +1,50 @@
# [PROJECT_NAME] Constitution
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. -->
## Core Principles
### [PRINCIPLE_1_NAME]
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME]
<!-- Example: II. CLI Interface -->
[PRINCIPLE_2_DESCRIPTION]
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats -->
### [PRINCIPLE_3_NAME]
<!-- Example: III. Test-First (NON-NEGOTIABLE) -->
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME]
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME]
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity -->
[PRINCIPLE_5_DESCRIPTION]
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles -->
## [SECTION_2_NAME]
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. -->
[SECTION_2_CONTENT]
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME]
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. -->
[SECTION_3_CONTENT]
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. -->
## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES]
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance -->
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE]
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 -->
+113
View File
@@ -0,0 +1,113 @@
# Implementation Plan: [FEATURE]
**Branch**: `[###-feature-name]` | **Date**: [DATE] | **Spec**: [link]
**Input**: Feature specification from `/specs/[###-feature-name]/spec.md`
**Note**: This template is filled in by the `/speckit-plan` command; its definition describes the execution workflow.
## Summary
[Extract from feature spec: primary requirement + technical approach from research]
## Technical Context
<!--
ACTION REQUIRED: Replace the content in this section with the technical details
for the project. The structure here is presented in advisory capacity to guide
the iteration process.
-->
**Language/Version**: [e.g., Python 3.11, Swift 5.9, Rust 1.75 or NEEDS CLARIFICATION]
**Primary Dependencies**: [e.g., FastAPI, UIKit, LLVM or NEEDS CLARIFICATION]
**Storage**: [if applicable, e.g., PostgreSQL, CoreData, files or N/A]
**Testing**: [e.g., pytest, XCTest, cargo test or NEEDS CLARIFICATION]
**Target Platform**: [e.g., Linux server, iOS 15+, WASM or NEEDS CLARIFICATION]
**Project Type**: [e.g., library/cli/web-service/mobile-app/compiler/desktop-app or NEEDS CLARIFICATION]
**Performance Goals**: [domain-specific, e.g., 1000 req/s, 10k lines/sec, 60 fps or NEEDS CLARIFICATION]
**Constraints**: [domain-specific, e.g., <200ms p95, <100MB memory, offline-capable or NEEDS CLARIFICATION]
**Scale/Scope**: [domain-specific, e.g., 10k users, 1M LOC, 50 screens or NEEDS CLARIFICATION]
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
[Gates determined based on constitution file]
## Project Structure
### Documentation (this feature)
```text
specs/[###-feature]/
├── plan.md # This file (/speckit-plan command output)
├── research.md # Phase 0 output (/speckit-plan command)
├── data-model.md # Phase 1 output (/speckit-plan command)
├── quickstart.md # Phase 1 output (/speckit-plan command)
├── contracts/ # Phase 1 output (/speckit-plan command)
└── tasks.md # Phase 2 output (/speckit-tasks command - NOT created by /speckit-plan)
```
### Source Code (repository root)
<!--
ACTION REQUIRED: Replace the placeholder tree below with the concrete layout
for this feature. Delete unused options and expand the chosen structure with
real paths (e.g., apps/admin, packages/something). The delivered plan must
not include Option labels.
-->
```text
# [REMOVE IF UNUSED] Option 1: Single project (DEFAULT)
src/
├── models/
├── services/
├── cli/
└── lib/
tests/
├── contract/
├── integration/
└── unit/
# [REMOVE IF UNUSED] Option 2: Web application (when "frontend" + "backend" detected)
backend/
├── src/
│ ├── models/
│ ├── services/
│ └── api/
└── tests/
frontend/
├── src/
│ ├── components/
│ ├── pages/
│ └── services/
└── tests/
# [REMOVE IF UNUSED] Option 3: Mobile + API (when "iOS/Android" detected)
api/
└── [same as backend above]
ios/ or android/
└── [platform-specific structure: feature modules, UI flows, platform tests]
```
**Structure Decision**: [Document the selected structure and reference the real
directories captured above]
## Complexity Tracking
> **Fill ONLY if Constitution Check has violations that must be justified**
| Violation | Why Needed | Simpler Alternative Rejected Because |
|-----------|------------|-------------------------------------|
| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
+131
View File
@@ -0,0 +1,131 @@
# Feature Specification: [FEATURE NAME]
**Feature Branch**: `[###-feature-name]`
**Created**: [DATE]
**Status**: Draft
**Input**: User description: "$ARGUMENTS"
## User Scenarios & Testing *(mandatory)*
<!--
IMPORTANT: User stories should be PRIORITIZED as user journeys ordered by importance.
Each user story/journey must be INDEPENDENTLY TESTABLE - meaning if you implement just ONE of them,
you should still have a viable MVP (Minimum Viable Product) that delivers value.
Assign priorities (P1, P2, P3, etc.) to each story, where P1 is the most critical.
Think of each story as a standalone slice of functionality that can be:
- Developed independently
- Tested independently
- Deployed independently
- Demonstrated to users independently
-->
### User Story 1 - [Brief Title] (Priority: P1)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently - e.g., "Can be fully tested by [specific action] and delivers [specific value]"]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
2. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 2 - [Brief Title] (Priority: P2)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
### User Story 3 - [Brief Title] (Priority: P3)
[Describe this user journey in plain language]
**Why this priority**: [Explain the value and why it has this priority level]
**Independent Test**: [Describe how this can be tested independently]
**Acceptance Scenarios**:
1. **Given** [initial state], **When** [action], **Then** [expected outcome]
---
[Add more user stories as needed, each with an assigned priority]
### Edge Cases
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right edge cases.
-->
- What happens when [boundary condition]?
- How does system handle [error scenario]?
## Requirements *(mandatory)*
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right functional requirements.
-->
### Functional Requirements
- **FR-001**: System MUST [specific capability, e.g., "allow users to create accounts"]
- **FR-002**: System MUST [specific capability, e.g., "validate email addresses"]
- **FR-003**: Users MUST be able to [key interaction, e.g., "reset their password"]
- **FR-004**: System MUST [data requirement, e.g., "persist user preferences"]
- **FR-005**: System MUST [behavior, e.g., "log all security events"]
*Example of marking unclear requirements:*
- **FR-006**: System MUST authenticate users via [NEEDS CLARIFICATION: auth method not specified - email/password, SSO, OAuth?]
- **FR-007**: System MUST retain user data for [NEEDS CLARIFICATION: retention period not specified]
### Key Entities *(include if feature involves data)*
- **[Entity 1]**: [What it represents, key attributes without implementation]
- **[Entity 2]**: [What it represents, relationships to other entities]
## Success Criteria *(mandatory)*
<!--
ACTION REQUIRED: Define measurable success criteria.
These must be technology-agnostic and measurable.
-->
### Measurable Outcomes
- **SC-001**: [Measurable metric, e.g., "Users can complete account creation in under 2 minutes"]
- **SC-002**: [Measurable metric, e.g., "System handles 1000 concurrent users without degradation"]
- **SC-003**: [User satisfaction metric, e.g., "90% of users successfully complete primary task on first attempt"]
- **SC-004**: [Business metric, e.g., "Reduce support tickets related to [X] by 50%"]
## Assumptions
<!--
ACTION REQUIRED: The content in this section represents placeholders.
Fill them out with the right assumptions based on reasonable defaults
chosen when the feature description did not specify certain details.
-->
- [Assumption about target users, e.g., "Users have stable internet connectivity"]
- [Assumption about scope boundaries, e.g., "Mobile support is out of scope for v1"]
- [Assumption about data/environment, e.g., "Existing authentication system will be reused"]
- [Dependency on existing system/service, e.g., "Requires access to the existing user profile API"]
+252
View File
@@ -0,0 +1,252 @@
---
description: "Task list template for feature implementation"
---
# Tasks: [FEATURE NAME]
**Input**: Design documents from `/specs/[###-feature-name]/`
**Prerequisites**: plan.md (required), spec.md (required for user stories), research.md, data-model.md, contracts/
**Tests**: The examples below include test tasks. Tests are OPTIONAL - only include them if explicitly requested in the feature specification.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Path Conventions
- **Single project**: `src/`, `tests/` at repository root
- **Web app**: `backend/src/`, `frontend/src/`
- **Mobile**: `api/src/`, `ios/src/` or `android/src/`
- Paths shown below assume single project - adjust based on plan.md structure
<!--
============================================================================
IMPORTANT: The tasks below are SAMPLE TASKS for illustration purposes only.
The /speckit-tasks command MUST replace these with actual tasks based on:
- User stories from spec.md (with their priorities P1, P2, P3...)
- Feature requirements from plan.md
- Entities from data-model.md
- Endpoints from contracts/
Tasks MUST be organized by user story so each story can be:
- Implemented independently
- Tested independently
- Delivered as an MVP increment
DO NOT keep these sample tasks in the generated tasks.md file.
============================================================================
-->
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: Project initialization and basic structure
- [ ] T001 Create project structure per implementation plan
- [ ] T002 Initialize [language] project with [framework] dependencies
- [ ] T003 [P] Configure linting and formatting tools
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core infrastructure that MUST be complete before ANY user story can be implemented
**⚠️ CRITICAL**: No user story work can begin until this phase is complete
Examples of foundational tasks (adjust based on your project):
- [ ] T004 Setup database schema and migrations framework
- [ ] T005 [P] Implement authentication/authorization framework
- [ ] T006 [P] Setup API routing and middleware structure
- [ ] T007 Create base models/entities that all stories depend on
- [ ] T008 Configure error handling and logging infrastructure
- [ ] T009 Setup environment configuration management
**Checkpoint**: Foundation ready - user story implementation can now begin in parallel
---
## Phase 3: User Story 1 - [Title] (Priority: P1) 🎯 MVP
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 1 (OPTIONAL - only if tests requested) ⚠️
> **NOTE: Write these tests FIRST, ensure they FAIL before implementation**
- [ ] T010 [P] [US1] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T011 [P] [US1] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 1
- [ ] T012 [P] [US1] Create [Entity1] model in src/models/[entity1].py
- [ ] T013 [P] [US1] Create [Entity2] model in src/models/[entity2].py
- [ ] T014 [US1] Implement [Service] in src/services/[service].py (depends on T012, T013)
- [ ] T015 [US1] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T016 [US1] Add validation and error handling
- [ ] T017 [US1] Add logging for user story 1 operations
**Checkpoint**: At this point, User Story 1 should be fully functional and testable independently
---
## Phase 4: User Story 2 - [Title] (Priority: P2)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 2 (OPTIONAL - only if tests requested) ⚠️
- [ ] T018 [P] [US2] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T019 [P] [US2] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 2
- [ ] T020 [P] [US2] Create [Entity] model in src/models/[entity].py
- [ ] T021 [US2] Implement [Service] in src/services/[service].py
- [ ] T022 [US2] Implement [endpoint/feature] in src/[location]/[file].py
- [ ] T023 [US2] Integrate with User Story 1 components (if needed)
**Checkpoint**: At this point, User Stories 1 AND 2 should both work independently
---
## Phase 5: User Story 3 - [Title] (Priority: P3)
**Goal**: [Brief description of what this story delivers]
**Independent Test**: [How to verify this story works on its own]
### Tests for User Story 3 (OPTIONAL - only if tests requested) ⚠️
- [ ] T024 [P] [US3] Contract test for [endpoint] in tests/contract/test_[name].py
- [ ] T025 [P] [US3] Integration test for [user journey] in tests/integration/test_[name].py
### Implementation for User Story 3
- [ ] T026 [P] [US3] Create [Entity] model in src/models/[entity].py
- [ ] T027 [US3] Implement [Service] in src/services/[service].py
- [ ] T028 [US3] Implement [endpoint/feature] in src/[location]/[file].py
**Checkpoint**: All user stories should now be independently functional
---
[Add more user story phases as needed, following the same pattern]
---
## Phase N: Polish & Cross-Cutting Concerns
**Purpose**: Improvements that affect multiple user stories
- [ ] TXXX [P] Documentation updates in docs/
- [ ] TXXX Code cleanup and refactoring
- [ ] TXXX Performance optimization across all stories
- [ ] TXXX [P] Additional unit tests (if requested) in tests/unit/
- [ ] TXXX Security hardening
- [ ] TXXX Run quickstart.md validation
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies - can start immediately
- **Foundational (Phase 2)**: Depends on Setup completion - BLOCKS all user stories
- **User Stories (Phase 3+)**: All depend on Foundational phase completion
- User stories can then proceed in parallel (if staffed)
- Or sequentially in priority order (P1 → P2 → P3)
- **Polish (Final Phase)**: Depends on all desired user stories being complete
### User Story Dependencies
- **User Story 1 (P1)**: Can start after Foundational (Phase 2) - No dependencies on other stories
- **User Story 2 (P2)**: Can start after Foundational (Phase 2) - May integrate with US1 but should be independently testable
- **User Story 3 (P3)**: Can start after Foundational (Phase 2) - May integrate with US1/US2 but should be independently testable
### Within Each User Story
- Tests (if included) MUST be written and FAIL before implementation
- Models before services
- Services before endpoints
- Core implementation before integration
- Story complete before moving to next priority
### Parallel Opportunities
- All Setup tasks marked [P] can run in parallel
- All Foundational tasks marked [P] can run in parallel (within Phase 2)
- Once Foundational phase completes, all user stories can start in parallel (if team capacity allows)
- All tests for a user story marked [P] can run in parallel
- Models within a story marked [P] can run in parallel
- Different user stories can be worked on in parallel by different team members
---
## Parallel Example: User Story 1
```bash
# Launch all tests for User Story 1 together (if tests requested):
Task: "Contract test for [endpoint] in tests/contract/test_[name].py"
Task: "Integration test for [user journey] in tests/integration/test_[name].py"
# Launch all models for User Story 1 together:
Task: "Create [Entity1] model in src/models/[entity1].py"
Task: "Create [Entity2] model in src/models/[entity2].py"
```
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup
2. Complete Phase 2: Foundational (CRITICAL - blocks all stories)
3. Complete Phase 3: User Story 1
4. **STOP and VALIDATE**: Test User Story 1 independently
5. Deploy/demo if ready
### Incremental Delivery
1. Complete Setup + Foundational → Foundation ready
2. Add User Story 1 → Test independently → Deploy/Demo (MVP!)
3. Add User Story 2 → Test independently → Deploy/Demo
4. Add User Story 3 → Test independently → Deploy/Demo
5. Each story adds value without breaking previous stories
### Parallel Team Strategy
With multiple developers:
1. Team completes Setup + Foundational together
2. Once Foundational is done:
- Developer A: User Story 1
- Developer B: User Story 2
- Developer C: User Story 3
3. Stories complete and integrate independently
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- Each user story should be independently completable and testable
- Verify tests fail before implementing
- Commit after each task or logical group
- Stop at any checkpoint to validate story independently
- Avoid: vague tasks, same file conflicts, cross-story dependencies that break independence
+78
View File
@@ -0,0 +1,78 @@
schema_version: "1.0"
workflow:
id: "speckit"
name: "Full SDD Cycle"
version: "1.0.0"
author: "GitHub"
description: "Runs specify → plan → tasks → implement with review gates"
requires:
# 0.8.5 is the first release with engine-side resolution of the
# ``integration: "auto"`` default. Older versions would treat "auto"
# as a literal integration key and fail at dispatch.
speckit_version: ">=0.8.5"
integrations:
# The four commands below (specify, plan, tasks, implement) are core
# spec-kit commands provided by every integration. The list here is an
# advisory, non-exhaustive compatibility hint following the documented
# ``any: [...]`` schema -- it is NOT a closed set. The workflow runs
# against any integration the project was initialized with, including
# ones not listed below, as long as that integration provides the four
# core commands referenced in ``steps``.
any:
- "alquimia"
- "claude"
- "copilot"
- "gemini"
- "opencode"
inputs:
spec:
type: string
required: true
prompt: "Describe what you want to build"
integration:
type: string
default: "auto"
prompt: "Integration to use (e.g. claude, copilot, gemini; 'auto' uses the project's initialized integration)"
scope:
type: string
default: "full"
enum: ["full", "backend-only", "frontend-only"]
steps:
- id: specify
command: speckit.specify
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-spec
type: gate
message: "Review the generated spec before planning."
options: [approve, reject]
on_reject: abort
- id: plan
command: speckit.plan
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: review-plan
type: gate
message: "Review the plan before generating tasks."
options: [approve, reject]
on_reject: abort
- id: tasks
command: speckit.tasks
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
- id: implement
command: speckit.implement
integration: "{{ inputs.integration }}"
input:
args: "{{ inputs.spec }}"
+13
View File
@@ -0,0 +1,13 @@
{
"schema_version": "1.0",
"workflows": {
"speckit": {
"name": "Full SDD Cycle",
"version": "1.0.0",
"description": "Runs specify \u2192 plan \u2192 tasks \u2192 implement with review gates",
"source": "bundled",
"installed_at": "2026-08-21T10:25:14.476954+00:00",
"updated_at": "2026-08-21T10:25:14.476954+00:00"
}
}
}
+16 -2
View File
@@ -10,8 +10,22 @@
### Stop
- docker compose -f docker-compose.prod.yml down
### list containers
### List Containers
- docker compose --env-file .env.development -f docker-compose.development.yml ps
### logs
### Logs
- docker compose --env-file .env.development -f docker-compose.development.yml logs -f
### Database Migrations
- **Local (using .env.development):**
- Create/apply new migration: `npx prisma migrate dev --name <name>`
- Push schema directly (prototype/sync): `npx prisma db push`
- Deploy pending migrations: `npm run prisma:deploy`
- **Inside Docker Container:**
- `docker exec -it support-api-development npx prisma migrate deploy`
### Database Seeding
- **Local:**
- `npm run prisma:seed` (or `npx tsx --env-file=.env.development prisma/seed/index.ts`)
- **Inside Docker Container:**
- `docker exec -it support-api-development npm run prisma:seed`
+6
View File
@@ -41,6 +41,9 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5434:5432"
volumes:
- postgres_development_data:/var/lib/postgresql
@@ -66,6 +69,9 @@ services:
- --requirepass
- ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_development_data:/data
+6 -4
View File
@@ -33,14 +33,15 @@ services:
postgres:
image: postgres:18-alpine
container_name: postgres-test
environment:
POSTGRES_DB: ${POSTGRES_DB}
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_HOST_AUTH_METHOD: trust
ports:
- "5432:5432"
volumes:
- postgres_test_data:/var/lib/postgresql
@@ -59,13 +60,14 @@ services:
redis:
image: redis:7-alpine
container_name: redis-test
command:
- redis-server
- --requirepass
- ${REDIS_PASSWORD}
ports:
- "6379:6379"
volumes:
- redis_test_data:/data
+58
View File
@@ -0,0 +1,58 @@
# SupportHub — Master Architecture & Implementation Guide
**System:** AI-First Customer Support + Ticket Management + Problem Resolution + Support Orchestration Platform
**Status:** Blueprint / pre-implementation
**Scope:** This guide consolidates the full product and engineering specification into a build-ready reference.
---
## How this guide is organized
| # | Document | Covers |
|---|----------|--------|
| 01 | [Product Vision & Principles](./01-product-vision-and-principles.md) | Core idea, example flow, system ownership boundaries, business model, UX principles, engineering rules, landing page |
| 02 | [Integration & Security](./02-integration-and-security.md) | Product integration model, credentials, service-to-service auth, request contracts, RBAC boundary, secrets |
| 03 | [AI Support Architecture](./03-ai-support-architecture.md) | AI-first flow, knowledge system, RAG, diagnosis, tools, runbooks, guided UX, verification, AI safety |
| 04 | [Ticketing & Problem Management](./04-ticketing-and-problem-management.md) | Ticket lifecycle, problem entity, investigation, root cause, solution, verification, resolution, messages, attachments |
| 05 | [Orchestration, SLA & Escalation](./05-orchestration-sla-escalation.md) | Orchestration engine, dynamic hierarchy, capability matching, assignment, SLA, escalation |
| 06 | [Database Schema](./06-database-schema.md) | Full entity catalog, field-level detail, relationships |
| 07 | [Backend Architecture](./07-backend-architecture.md) | Tech stack, module structure, request flow, events, jobs, audit |
| 08 | [Frontend Architecture](./08-frontend-architecture.md) | Customer, agent, admin frontends; real-time UX |
| 09 | [Testing, Observability & CI/CD](./09-testing-observability-cicd.md) | Test strategy, critical E2E flows, logging/metrics, Jenkins pipeline |
| 10 | [Implementation Roadmap](./10-implementation-roadmap.md) | 11-phase delivery plan, success criteria, open business decisions |
| 11 | [Architect's Additions: Gaps & Recommendations](./11-architect-additions-gaps-and-recommendations.md) | Production concerns not in the original spec — idempotency, webhooks, RLS, prompt injection, AI cost governance, CSAT, data retention, and more |
---
## One-paragraph summary
SupportHub sits behind any number of existing SaaS products and never owns identity, tenancy, product access, subscriptions, or RBAC — that authority stays with the existing SaaS. When a customer reports a problem from inside a product, SupportHub creates a durable ticket immediately, then routes the problem to a **product-aware AI Support Agent** that classifies it, retrieves scoped knowledge (RAG), diagnoses a likely cause with a confidence score, and either executes approved tools or guides the customer through a controlled runbook. Resolution is only recorded after **actual verification**, not a customer's say-so. When AI can't safely or confidently resolve the problem, it escalates — with full context — into a **configuration-driven orchestration engine** that picks the right dynamic support hierarchy node, matches capability before availability, assigns an agent through a pluggable concurrency-safe strategy, and enforces SLA policies that respect business calendars and pause/resume correctly. Everything (assignment, escalation, SLA, knowledge, resolution) is audit-logged and reportable.
## Core end-to-end flow
```
Customer → Product App → Support Center → SupportHub API
→ Ticket/Case created (status: NEW)
→ AI understands problem → classifies → retrieves knowledge
→ Diagnosis (with confidence) → Direct solution OR guided runbook
→ Customer action → System verification
├─ Verified solved → AI_RESOLVED → (confirm) → RESOLVED → CLOSED
└─ Not solved/low confidence → HUMAN_ESCALATION
→ Orchestration Engine (capability → hierarchy → team)
→ Assignment (strategy-based, concurrency-safe)
→ SLA applied (business-calendar aware)
→ Investigation → Root Cause → Solution → Verification
→ Resolution → Customer confirmation → CLOSED
```
## Non-negotiable boundaries (repeated throughout this guide because they matter most)
- **SaaS is the only source of truth** for users, tenants, products, product access, subscriptions, permissions, RBAC, and authentication. SupportHub stores only external references (`externalUserId`, `externalTenantId`, `externalProductId`).
- **SupportHub is the only source of truth** for tickets, problems, support org structure, routing/assignment, SLA, escalation, investigation/root cause/solution/verification/resolution, knowledge, and support audit trail.
- **AI recommends; deterministic policy decides.** The LLM never gets unrestricted backend access, never invents troubleshooting steps or product behavior, and never executes a high-risk action without a permission and policy check.
- **Nothing is hardcoded** that the spec calls out as configurable: support hierarchy, SLA values, escalation paths, assignment strategy, routing rules. All of it is admin-configurable data, not code.
- **Verification is evidence-based**, not customer-confirmation-based, wherever a system signal is available.
## Reading order recommendation
If you're briefing an engineering team from scratch, read in document order (01 → 10). If you're validating a specific subsystem, jump directly to the relevant document — each is self-contained with cross-references back to this index.
+145
View File
@@ -0,0 +1,145 @@
# 01 — Product Vision & Principles
## 1. What SupportHub is (and isn't)
SupportHub is an **AI-First Customer Support + Ticket Management + Problem Resolution + Support Orchestration Platform**. It is not a basic helpdesk, not a generic chatbot wrapper, and not a second identity/RBAC system.
It integrates with one or more existing SaaS platforms that already own:
- Users, tenants/organizations
- Products, product registration, product access
- Subscriptions/purchases, permissions, RBAC
- Authentication
- User↔tenant and user↔product relationships
SupportHub never rebuilds any of the above. It integrates with the SaaS through secure APIs and per-product integration credentials.
## 2. Core business idea
A customer using any registered SaaS product reports a problem. SupportHub attempts to resolve it with a **product-aware AI Support Agent** before any human is involved. The agent:
1. Understands the customer's problem
2. Identifies the affected product
3. Identifies the feature/module
4. Classifies the problem
5. Searches relevant product knowledge
6. Searches known issues and failure cases
7. Diagnoses the likely cause
8. Provides product-specific guidance
9. Executes approved tools/actions when appropriate
10. Asks the customer to follow instructions when required
11. Verifies whether the problem was actually solved
12. Marks the case AI-resolved on success
13. Escalates to human support when it cannot safely or confidently solve it
**The customer never needs to understand the internal support hierarchy** — they just report a problem and see progress toward a resolution.
## 3. Reference example: DocuQube
Used throughout this guide as the canonical example product.
**Scenario:** Customer uploads a PDF to DocuQube. PDF upload succeeds, OCR succeeds, HTML conversion fails. Customer clicks Help/Support inside DocuQube.
```
Ticket: DQB-2026-00567
Product: DocuQube
Customer: Tenant/User from SaaS (via external reference)
Problem: PDF to HTML conversion failed
Status: AI_ANALYZING
```
This ticket is created **immediately**, at the start of the journey — not after AI gives up. See [04 — Ticketing & Problem Management](./04-ticketing-and-problem-management.md).
## 4. System ownership boundary
| Owned by existing SaaS (authoritative) | Owned by SupportHub (authoritative) |
|---|---|
| User identity | Support sessions |
| Tenant identity | Tickets |
| Product identity | Problems |
| Product access / subscription | Support teams, agents, skills/capabilities |
| Product permissions / RBAC | Support hierarchy |
| Authentication | Routing, assignment |
| | SLA, escalation |
| | Investigation, root cause, solution, verification, resolution |
| | Support messages, attachments |
| | Knowledge, runbooks |
| | Support audit, support analytics |
SupportHub stores `externalUserId`, `externalTenantId`, `externalProductId` as **references only** — it never becomes a second SaaS-style identity/tenant/RBAC platform.
## 5. Business model
| SaaS decides | SupportHub decides |
|---|---|
| Who owns the product | How support is delivered |
| Which tenant has access | How problems are classified |
| Which features are purchased | How AI handles them |
| Which permissions exist | Which support team handles them |
| | How tickets are assigned |
| | How SLA is enforced |
| | When escalation happens |
| | How resolution is recorded |
Support is **enabled by default** for any product registered in the SaaS — treated as a platform capability of the product, not an opt-in the customer must separately configure, unless/until premium support tiers are introduced as a business rule. The customer never creates a separate SupportHub account.
## 6. UX principle by persona
| Persona | Experience should be |
|---|---|
| **Customer** | Simple, guided, trustworthy, product-aware. Never sees internal hierarchy, assignment algorithms, agent workload, escalation rules, internal notes, or routing logic. |
| **Agent** | Information-dense, fast, operational, context-rich. Continues from AI context — never restarts diagnosis from zero. |
| **Admin** | Configurable, visual, rule-driven, auditable. |
### Customer journey (happy path)
```
Problem → AI help → Guided solution → Verification → Resolved
```
### Customer journey (escalation path)
```
Problem → AI attempts → Escalation → Human Support → Resolution
```
### Critical UI rule
The customer should **not** land on a generic "ticket system." The first thing they see is a **Support Center** with "How can we help you?" — they describe a problem, and the system absorbs all the complexity behind that single interaction.
## 7. Landing page positioning (if a public SupportHub site is needed)
- Positioning: *"AI-first support and intelligent resolution platform."*
- Hero: *"Resolve customer problems before they become support tickets."*
- Explain: product-aware AI, guided troubleshooting, human support orchestration, capability-based assignment, SLA, escalation, SaaS integration.
- **Do not** position this as a basic helpdesk.
## 8. Engineering rules (non-negotiable)
**Never:**
- Hardcode support hierarchy, SLA values, escalation paths, or assignment decisions
- Duplicate SaaS RBAC inside SupportHub
- Put Prisma queries in controllers, or business logic in routes
- Allow the AI unrestricted backend access
- Store large files in PostgreSQL
- Use in-memory SLA timers (e.g., `setTimeout`) for production enforcement
- Create giant global services, circular module dependencies, or deep cross-module imports
**Always:**
- Use configuration-driven logic for anything the business can change without a deploy
- Keep module boundaries clear, exposed only via each module's public `index.ts`
- Validate all input; audit all important operations
- Test concurrent operations (assignment races, idempotent job handlers)
- Preserve full AI history and full resolution history
- Keep problem and ticket as separate, related entities
- Verify actual resolution with evidence, not customer assertion, wherever possible
- Keep the SaaS as the sole identity/access authority
## 9. Final architectural principle
```
PROBLEM → UNDERSTAND → KNOWLEDGE → DIAGNOSE → GUIDE → VERIFY → RESOLVE
If AI cannot resolve:
PROBLEM → HUMAN SUPPORT → ORCHESTRATE → ASSIGN → SLA
→ INVESTIGATE → ROOT CAUSE → SOLUTION → VERIFY → RESOLVE → CLOSE
```
SupportHub is: **AI-first, problem-centric, configuration-driven, product-aware, human-assisted, SLA-aware, escalation-aware, multi-product, API-integrated, enterprise-ready.**
+91
View File
@@ -0,0 +1,91 @@
# 02 — Integration & Security
## 1. Product integration model
Every SaaS product that wants support must be **registered as an integration client** in SupportHub, with its own credential. Credentials are never shared globally across products.
```
Product: DocuQube
Product ID: PROD_DQ_001
Support Integration: Enabled
Integration Credential: PRODUCT_DQ_CREDENTIAL
```
A second product gets an entirely separate `Product ID` and credential — never reuse one credential across products.
## 2. What SupportHub must validate on every inbound request
- The calling product's identity
- The integration credential presented
- Product status (active/suspended/deprecated)
- User/tenant context accompanying the request
- The allowed integration scope for that credential
**Never trust a raw `userId` or `productId` blindly** — every value must be validated against the registered integration and its scope before use.
## 3. Inbound request contract (conceptual)
```ts
interface ProductToSupportHubRequest {
productId: string;
tenantId: string;
userId: string;
source: string; // e.g. "docuqube-web", "docuqube-mobile"
problem: string; // free-text customer description
feature?: string; // e.g. "pdf_to_html"
referenceIds?: string[]; // e.g. documentId, jobId — product-specific evidence handles
context?: Record<string, unknown>;
}
```
## 4. Service-to-service authentication
Use production-appropriate mechanisms, chosen per integration risk profile:
- **Signed service tokens** (short-lived, scoped to a product)
- **OAuth2 client credentials** grant where suitable
- **mTLS** for high-trust server-to-server channels
- **Credential rotation** — must be supported without downtime
- **Credential revocation** — immediate effect, audited
- **Audit logging** of every integration authentication event (success and failure)
## 5. RBAC boundary
SupportHub **integrates with** the SaaS's existing RBAC — it does not reimplement it.
- Support-domain authorization (who can see which ticket, which admin config, which agent queue) is SupportHub's own concern and lives entirely within SupportHub's data model (teams, agents, hierarchy scope).
- Customer-facing authorization (does this user have access to this product at all) is always deferred to the SaaS via the validated integration context — SupportHub does not maintain a parallel "does this user own this product" table.
- **A customer must never be able to access another customer's tickets.** Every ticket query must be scoped by the validated `externalTenantId`/`externalUserId` from the authenticated session, never by client-supplied values alone.
## 6. Security requirements checklist
- [ ] Secure product integration (per-product credentials, scoped)
- [ ] Authentication context validated on every request
- [ ] RBAC integration with SaaS (never duplicated)
- [ ] Support-domain authorization (teams/hierarchy/product scope)
- [ ] Customer isolation (tenant/user scoping on every query)
- [ ] Rate limiting per integration and per user
- [ ] Input validation (schema-first, reject unknown/extra fields)
- [ ] Secure file handling (validation, size limits, malware scanning — see [04](./04-ticketing-and-problem-management.md#attachments))
- [ ] Encrypted transport (TLS everywhere, mTLS where appropriate)
- [ ] Encrypted sensitive storage at rest
- [ ] Secret management (never in source, never in `NEXT_PUBLIC_*`)
- [ ] Audit logging for all security-relevant actions (append-only from the application's perspective)
## 7. Environment & secrets handling
```
.env.example
.env.development.example
.env.test.example
.env.production.example
```
- Real environment values are **never committed**.
- Production secrets are injected via CI/CD infrastructure (Jenkins credentials store), not checked into any env file.
- Use environment validation at boot (fail fast if a required var is missing/malformed).
- Only browser-safe variables use the `NEXT_PUBLIC_` prefix — secrets must never be exposed this way.
## 8. AI-specific safety boundary (summary — full detail in [03](./03-ai-support-architecture.md#ai-safety-and-control))
The AI must never: invent product behavior or configuration, invent troubleshooting steps, execute unauthorized actions, access arbitrary customer data, expose internal notes or private knowledge, or modify support configuration/SLA/hierarchy. Deterministic application policy is always the actual decision-maker for anything with real-world effect.
+237
View File
@@ -0,0 +1,237 @@
# 03 — AI Support Architecture
The AI Support Agent is the **first support layer**, not a generic chatbot. It must be product-aware, evidence-driven, and tightly bounded by deterministic policy.
## 1. High-level flow
```
Customer Problem
→ Problem Classification
→ Knowledge Retrieval (RAG)
→ Relevant Context
→ AI Reasoning
→ Next Action (direct solution / guided runbook / tool call / escalate)
```
## 2. Product knowledge system
Knowledge is **retrieved, not stuffed into one giant system prompt.** Use a RAG architecture scoped per product.
Conceptual structure per product:
```
Product
├── Overview
├── Features
├── Troubleshooting
├── Known Issues
├── Error Catalog
├── Runbooks
├── FAQs
├── Resolution Procedures
└── Product Operations
```
Example knowledge entry:
```
Knowledge ID: KB-DQ-102
Product: DocuQube
Feature: PDF → HTML
Type: Known Issue
Problem: Conversion fails for complex layouts.
Symptoms: Layout parser failure.
Error: LAYOUT_PARSE_042
Cause: Specific PDF layouts fail through the primary parser.
Recommended: Use fallback parser.
Verification: Retry conversion and confirm output.
Escalation: Escalate if fallback parser fails.
```
Knowledge must be:
- Versioned, searchable, auditable
- Product-scoped, and category-scoped where necessary
- Permission-aware (never expose raw internal knowledge to customers — the AI converts retrieved knowledge into simple, product-specific guidance)
- Maintainable by administrators, with quality metadata:
| Field | Purpose |
|---|---|
| `version` | Change tracking |
| `status` | draft / published / unpublished |
| `effectiveDate` | When it becomes eligible for retrieval |
| `productScope`, `featureScope`, `categoryScope` | Retrieval filters |
| `validationStatus` | Has this been verified to actually work? |
| `owner`, `lastReview` | Accountability / staleness detection |
| `source` | Where it originated (runbook author, resolved ticket, docs) |
**The AI should prefer validated knowledge** over unvalidated entries when both are retrieved.
## 3. Knowledge retrieval example
Customer says: *"My PDF is not converting to HTML."*
The system resolves:
```
Product: DocuQube
Feature: PDF → HTML
Problem type: Conversion Failure
```
Then searches: feature documentation → error catalog → known issues → troubleshooting → runbooks → previously validated solutions. Only relevant, filtered knowledge is returned to the AI's context — never a bulk dump.
## 4. AI diagnosis
The AI produces a **structured** diagnosis, not prose alone:
```json
{
"product": "docuqube",
"feature": "pdf_to_html",
"problemType": "conversion_failure",
"severity": "medium",
"confidence": 0.91,
"possibleCauses": ["unsupported_layout", "parser_failure", "processing_timeout"]
}
```
### Confidence policy (configurable)
| Confidence band | Behavior |
|---|---|
| High | AI may proceed automatically |
| Medium | AI may ask additional diagnostic questions |
| Low | Escalate to human support |
Thresholds are stored in configuration, never hardcoded, and must be tunable per product/category without a deploy.
## 5. AI tool system
The AI **requests** tools; the **application decides** whether execution is allowed. The LLM never gets unrestricted database/application access.
Example tools (DocuQube):
| Tool | Purpose |
|---|---|
| `getDocumentStatus()` | Read current processing state |
| `getDocumentMetadata()` | Read document metadata |
| `getProcessingStatus()` | Read pipeline stage status |
| `getErrorDetails()` | Read structured error info |
| `retryConversion()` | Re-trigger conversion job |
| `retryOCR()` | Re-trigger OCR job |
| `enableFallbackParser()` | Toggle fallback parser for this document |
| `checkServiceStatus()` | Read upstream service health |
| `getSupportedFileTypes()` | Read static capability info |
| `escalateToSupport()` | Trigger human escalation |
Each tool definition must include:
```ts
interface AITool {
name: string;
description: string;
inputSchema: ZodSchema;
outputSchema: ZodSchema;
permission: string; // permission required to invoke
riskLevel: "low" | "medium" | "high";
supportedProducts: string[];
auditRequired: boolean;
}
```
**High-risk operations require deterministic policy checks and/or human approval** before execution — the AI's request is a proposal, not an authorization.
## 6. Troubleshooting runbook engine
Runbooks are **configurable step sequences**, not something the LLM improvises. The AI communicates the runbook naturally in conversation; the workflow engine controls which steps are actually permitted next.
Example — `PDF_HTML_CONVERSION_FAILURE`:
```
Step 1: Check file size → if over limit, guide customer to reduce size
Step 2: Check file type
Step 3: Check document structure
Step 4: Try fallback parser
Step 5: Verify conversion → if still failing, escalate via configured path
```
**Do not allow the LLM to invent arbitrary troubleshooting steps.** The runbook engine is the source of the permitted sequence; the AI's job is presentation and interpretation of results, not authorship of new steps.
## 7. Guided customer experience
When the AI identifies a known solution, it presents:
```
Problem → Likely cause → Recommended action → Customer instruction → Verification
```
Example:
> "Your document failed during layout processing." → "Enable fallback parser."
>
> 1. Open Conversion Settings
> 2. Open Advanced
> 3. Enable *Use Fallback Parser*
> 4. Save
> 5. Retry conversion
The system then **waits** for the customer/product action rather than assuming completion.
## 8. Verification (first-class, evidence-based)
**Never assume** "customer says done" equals "problem resolved." Prefer actual system evidence.
```
Conversion started → processing completed → HTML generated → output validated
→ Verification = SUCCESS → only then can AI mark "Resolved by AI"
```
Supported verification modes:
- Product signal verification (webhook/event from the product confirming success)
- Automated verification (poll a status endpoint via an approved tool)
- Customer confirmation (used as a secondary/confirming signal, not primary evidence)
- Agent verification (for human-handled cases)
## 9. AI safety and control
**AI must never:**
- Invent product behavior, configuration settings, or troubleshooting steps
- Execute unauthorized actions
- Access arbitrary customer data
- Expose internal notes or private knowledge
- Modify support configuration, SLA policies, or hierarchy
- Escalate arbitrarily without going through policy evaluation
**AI can recommend. Deterministic application policies decide.** Tool execution is always permission-aware, and high-risk actions require stronger controls (policy check and/or human approval) regardless of AI confidence.
## 10. Escalation trigger conditions (AI → Human)
Escalate when any of the following hold:
- AI cannot identify the issue
- Confidence is below the configured threshold
- No matching knowledge exists
- The runbook is exhausted without success
- A required tool execution fails
- The problem requires human judgment/intervention
- The problem looks like a product defect
- It's flagged as a critical incident
- The customer explicitly asks for a human
- Policy requires a human for this case type
- The AI cannot safely execute a required action
When this happens, the **AI session hands off to a human support ticket with full context** — see [04](./04-ticketing-and-problem-management.md) and [05](./05-orchestration-sla-escalation.md).
Example AI hand-off summary:
```
Problem: PDF → HTML conversion failure.
Diagnosis: Layout parser issue.
Steps attempted:
✓ File validation
✓ Processing status check
✓ Fallback parser
✗ Still failed
AI confidence: 62%
Recommendation: Investigate conversion service.
```
+165
View File
@@ -0,0 +1,165 @@
# 04 — Ticketing & Problem Management
## 1. Ticket creation timing
The operational ticket/case is created **at the very start** of the support journey — not after AI fails. This preserves the complete interaction from the first moment: problem, diagnosis, knowledge used, AI messages, tool calls, failed attempts, customer actions, timestamps, evidence, and escalation history.
### Example lifecycle
```
NEW → AI_ANALYZING → AI_TROUBLESHOOTING → AI_VERIFYING
→ AI_RESOLVED
or
→ HUMAN_ESCALATION
```
## 2. Problem is first-class — separate from ticket
**Ticket** = the operational container tracking the interaction.
**Problem** = the actual thing being solved, which can outlive and span multiple tickets.
Problem contains:
- Problem statement, symptoms, impact
- Product, feature, category, problem type
- Severity, customer impact, business impact
- Environment, evidence, related tickets
Recurring-problem support:
```
Problem → Ticket A
→ Ticket B
→ Ticket C
```
This lets SupportHub recognize "this is the same underlying problem occurring again" rather than treating every occurrence as unrelated.
## 3. Human support flow (post-escalation)
```
Ticket → Orchestration → Capability → Support Hierarchy → Team
→ Eligible Agents → Assignment → SLA
→ Investigation → Root Cause → Solution → Verification
→ Resolution → Closure
```
If an agent cannot solve the issue:
```
Current Support Node → Evaluate escalation rules → Target Support Node
→ Target Team → Availability → Assignment → Continue SLA
```
## 4. Investigation (structured, not free-text notes)
Store:
- Investigator, timestamp
- Findings, evidence, internal notes, references
- Investigation status
Example:
```
Investigation: Payment service logs checked.
Finding: Webhook was received.
Finding: Payment processing failed.
```
## 5. Root cause — separate from investigation
Investigation is *what was found*. Root cause is *why it happened*, and is its own record.
```
Problem: PDF conversion fails.
Investigation: Layout parser returns error.
Root Cause: Parser cannot handle a specific table structure.
```
Root cause types to support: technical cause, configuration cause, external dependency cause, business cause, contributing factor.
## 6. Solution — proposed vs. implemented
Keep these distinct fields/states, not one blob:
- Proposed solution
- Approved solution
- Implemented solution
- Implementation notes
- Implemented by / implementation timestamp
```
Proposed: Enable fallback parser.
Implemented: Fallback parser enabled and conversion retried.
```
## 7. Verification — after implementation
```
Solution → Verification
```
Verification types: automated, technical test, customer confirmation, agent confirmation.
If verification fails:
```
Verification → Investigation (re-open investigation)
or
Verification → Escalation (escalate further)
```
## 8. Resolution — separate from solution
**Solution** = what was done. **Resolution** = the final outcome.
```
Solution: Fallback parser enabled.
Verification: HTML generated successfully.
Resolution: Customer document successfully converted.
```
## 9. Customer confirmation & reopen
Configurable confirmation flow:
```
RESOLUTION_PENDING_CUSTOMER → Customer confirms → RESOLVED → CLOSED
```
or
```
RESOLVED → configured waiting period (auto-close) → CLOSED
```
**Reopen must be supported** — a customer or agent can reopen a closed ticket, which should re-enter the appropriate lifecycle stage (and, per [05](./05-orchestration-sla-escalation.md), repeated reopens are themselves an escalation trigger).
## 10. Messages
Message types:
| Type | Visible to customer? |
|---|---|
| `CUSTOMER_MESSAGE` | Yes |
| `AI_MESSAGE` | Yes |
| `AGENT_MESSAGE` | Yes |
| `INTERNAL_NOTE` | **No — never** |
| `SYSTEM_EVENT` | Depends on event (status changes typically yes) |
| `INVESTIGATION_NOTE` | No |
| `SOLUTION_NOTE` | No |
**Internal notes must never be shown to customers** — enforce this at the API/serialization layer, not just in the UI.
## 11. Attachments
Supported types: screenshots, PDFs, logs, videos, documents.
**Storage:** object storage (S3-compatible in production, MinIO for local dev). **Never store large binary files in PostgreSQL** — store metadata + object storage reference only.
Required capabilities:
- File validation (type/size)
- Size limits (configurable, per product/tenant if needed)
- Malware scanning before the file is considered available
- Secure download via expiring, authorization-checked URLs
- Authorization scoped to the ticket's tenant/user context
## 12. Domain events emitted by this subsystem
`TicketCreated`, `ProblemCreated`, `TicketClassified`, `InvestigationStarted`, `RootCauseIdentified`, `SolutionProposed`, `SolutionImplemented`, `VerificationCompleted`, `TicketResolved`, `TicketClosed`, `TicketReopened`. Full event catalog and consumers are in [07 — Backend Architecture](./07-backend-architecture.md#domain-events).
+154
View File
@@ -0,0 +1,154 @@
# 05 — Orchestration, SLA & Escalation
This is the central decision-making subsystem for human support. It is entirely **configuration-driven** — no hardcoded `L1 → L2 → L3 → L4` levels anywhere.
## 1. Orchestration engine
Determines, for every escalated ticket: what problem is this, what capability is needed, which support path, which team, which agents, which assignment strategy, which SLA, which escalation policy.
```
Product → Problem Type → Category → Priority → Severity
→ Required Capability → Business Rules
→ Dynamic Support Hierarchy → Team → Eligible Agents
→ Assignment Strategy → SLA → Escalation Policy
```
## 2. Dynamic support hierarchy
Administrators configure arbitrary support nodes — different products can have completely different hierarchies, and changing one must never require a code change.
Example:
```
DocuQube Support
├── General Support
├── Document Processing
│ ├── OCR Specialist
│ └── Conversion Specialist
└── Engineering
├── Backend
└── Infrastructure
```
A hierarchy node's fields:
```ts
interface HierarchyNode {
id: string;
name: string;
parentId?: string;
order: number;
team: string;
skills: string[]; // capability tags this node covers
productScope: string[];
categoryScope: string[];
priorityScope: string[];
assignmentStrategy: AssignmentStrategy;
slaPolicyId: string;
escalationPolicyId: string;
entryConditions: RuleExpression;
exitConditions: RuleExpression;
active: boolean;
}
```
## 3. Capability and skill matching
**Capability is evaluated before availability.** Example:
```
Rahul: DocuQube, PDF, Conversion
Aamir: Billing, Payments
Sahil: Infrastructure, DevOps
Problem: DocuQube PDF conversion failure → Eligible: Rahul
```
Only after the eligible set is computed does the system consider: active state, availability, working hours, current workload, team, hierarchy node.
## 4. Assignment engine
Must be **pluggable**. Supported strategies:
| Strategy | Behavior |
|---|---|
| `ROUND_ROBIN` | Cycles through eligible agents only; must be concurrency-safe |
| `LEAST_LOADED` | Picks the eligible agent with lowest current workload |
| `SKILL_BASED` | Weighted match on skill depth, not just presence |
| `MANUAL` | Human picks the assignee |
| `DIRECT` | Explicit target (e.g. reassign to a named agent) |
| `PRIORITY_BASED` | Priority ticket preempts queue position |
**Round robin only operates over the eligible-agent set** (post capability-match), and **must be concurrency-safe** — two tickets arriving simultaneously must never corrupt the round-robin cursor or double-assign. Use a database-level lock/transaction or an atomic Redis operation, not an in-memory counter.
**Assignment history must be recorded** for every assignment and reassignment (who, when, why, strategy used).
## 5. SLA engine
SLA policies are **configuration**, never a hardcoded number. Policies may depend on: product, category, problem type, priority, severity, support node, customer type, business calendar.
### SLA types to support
- First response SLA
- Investigation SLA
- Resolution SLA
- Customer response SLA
- States: warning, pause, resume, breach, completion
### Business-calendar awareness
Support business hours, weekends, holidays, time zones, and per-team schedules. **Do not compute enterprise SLA as `createdAt + N hours`** — that ignores calendars entirely and will silently violate real commitments.
### SLA pause/resume
```
IN_PROGRESS → WAITING_FOR_CUSTOMER → SLA PAUSED
WAITING_FOR_CUSTOMER → IN_PROGRESS → SLA RESUMES
```
SLA calculations must be **durable** — never dependent on an in-memory timer that resets on a process restart. Use durable jobs (BullMQ) with persisted due-times recomputed against the business calendar, not `setTimeout`.
## 6. Escalation engine
Escalation is **rule-driven**, not `if L1 then L2`.
Example rule:
```
IF resolution SLA breached AND priority = critical
THEN move to configured escalation node, notify manager, create escalation event
```
### Escalation triggers
- First response breach
- Resolution breach
- Investigation breach
- Inactivity
- Priority increase
- Customer escalation request
- Repeated reopen
- Manual escalation
- Product defect identified
- External dependency timeout
- Critical incident
**All escalation events must be auditable.**
## 7. End-to-end human support flow
```
Ticket → Orchestration → Capability → Support Hierarchy → Team
→ Eligible Agents → Assignment → SLA
→ Investigation → Root Cause → Solution → Verification
→ Resolution → Closure
```
Re-escalation when an agent can't solve it:
```
Current Support Node → Evaluate escalation rules → Target Support Node
→ Target Team → Availability → Assignment → Continue SLA
```
## 8. Module implication
Because this subsystem contains genuine decision logic (not just CRUD), the `orchestration` module needs the extended internal structure — `engine/`, `rules/`, `strategies/`, `calculators/` — in addition to the standard controller/service/repository layers. See [07 — Backend Architecture](./07-backend-architecture.md#module-internal-structure).
+549
View File
@@ -0,0 +1,549 @@
# 06 — Database Schema
**Primary database:** PostgreSQL. **ORM:** Prisma. All schema below is conceptual/pseudo-Prisma — refine field types and add indexes during Phase 1 modeling (see [10 — Implementation Roadmap](./10-implementation-roadmap.md)).
Cross-cutting requirements for every table below:
- Migrations tracked in version control
- Indexes on every foreign key and every field used in ticket/queue filtering
- Unique constraints where the spec implies natural keys (e.g. `productId` + credential)
- Optimistic or transactional concurrency control wherever two actors could race (assignment, SLA state, hierarchy edits)
- `createdAt`/`updatedAt` on every table; soft-delete or status field where records must never disappear (audit, escalation events)
---
## Domain: Integration / Catalog
```prisma
model Product {
id String @id @default(cuid())
externalProductId String @unique // reference into SaaS, not authoritative
name String
supportEnabled Boolean @default(true)
status String // active | suspended | deprecated
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
integration ProductIntegration?
knowledgeEntries KnowledgeEntry[]
runbooks Runbook[]
tickets Ticket[]
}
model ProductIntegration {
id String @id @default(cuid())
productId String @unique
product Product @relation(fields: [productId], references: [id])
credentialRef String // pointer into secret manager, never the raw secret
authMechanism String // signed_token | oauth2_client_credentials | mtls
allowedScope Json // structured scope definition
rotatedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
}
model CustomerReference {
id String @id @default(cuid())
externalUserId String
externalTenantId String
createdAt DateTime @default(now())
@@unique([externalUserId, externalTenantId])
}
```
## Domain: Ticketing
```prisma
model Ticket {
id String @id @default(cuid())
code String @unique // e.g. DQB-2026-00567
productId String
product Product @relation(fields: [productId], references: [id])
problemId String
problem Problem @relation(fields: [problemId], references: [id])
externalUserId String
externalTenantId String
status String // NEW, AI_ANALYZING, AI_TROUBLESHOOTING, AI_VERIFYING,
// AI_RESOLVED, HUMAN_ESCALATION, IN_PROGRESS,
// WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER,
// RESOLVED, CLOSED, REOPENED
priority String
severity String
categoryId String?
problemTypeId String?
assignmentId String?
slaRunId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages TicketMessage[]
attachments TicketAttachment[]
aiSessions AISupportSession[]
assignments Assignment[]
escalationEvents EscalationEvent[]
}
model Problem {
id String @id @default(cuid())
statement String
symptoms String
impact String?
productId String
featureId String?
categoryId String?
problemTypeId String?
severity String
customerImpact String?
businessImpact String?
environment String?
createdAt DateTime @default(now())
tickets Ticket[]
investigations Investigation[]
rootCauses RootCause[]
solutions Solution[]
}
model TicketMessage {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
type String // CUSTOMER_MESSAGE, AI_MESSAGE, AGENT_MESSAGE, INTERNAL_NOTE,
// SYSTEM_EVENT, INVESTIGATION_NOTE, SOLUTION_NOTE
authorRef String // agentId, "ai", or externalUserId
body String
visibleToCustomer Boolean @default(true)
createdAt DateTime @default(now())
}
model TicketAttachment {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
storageKey String // S3/MinIO object key, not the file itself
fileName String
mimeType String
sizeBytes Int
scanStatus String // pending | clean | infected | rejected
uploadedBy String
createdAt DateTime @default(now())
}
```
## Domain: Category / Problem Type / Priority
```prisma
model Category { id String @id @default(cuid()) name String productId String? active Boolean @default(true) }
model ProblemType { id String @id @default(cuid()) name String categoryId String? active Boolean @default(true) }
model PriorityPolicy {
id String @id @default(cuid())
name String
productId String?
categoryId String?
rules Json // structured priority derivation rules
active Boolean @default(true)
}
```
## Domain: Support Hierarchy / Teams / Agents
```prisma
model HierarchyNode {
id String @id @default(cuid())
name String
parentId String?
parent HierarchyNode? @relation("HierarchyTree", fields: [parentId], references: [id])
children HierarchyNode[] @relation("HierarchyTree")
order Int
teamId String?
skills String[]
productScope String[]
categoryScope String[]
priorityScope String[]
assignmentStrategy String
slaPolicyId String?
escalationPolicyId String?
entryConditions Json?
exitConditions Json?
active Boolean @default(true)
}
model Team {
id String @id @default(cuid())
name String
active Boolean @default(true)
agents Agent[]
}
model Agent {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id])
name String
active Boolean @default(true)
skills AgentSkill[]
availability AgentAvailability?
}
model AgentSkill {
id String @id @default(cuid())
agentId String
agent Agent @relation(fields: [agentId], references: [id])
skillTag String
level Int // proficiency, used by SKILL_BASED strategy
}
model AgentAvailability {
id String @id @default(cuid())
agentId String @unique
agent Agent @relation(fields: [agentId], references: [id])
status String // available | busy | away | offline
workingHours Json // per business calendar
currentLoad Int @default(0)
}
```
## Domain: Assignment
```prisma
model Assignment {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String
strategy String
assignedAt DateTime @default(now())
unassignedAt DateTime?
reason String?
}
model AssignmentHistory {
id String @id @default(cuid())
ticketId String
agentId String?
action String // assigned | reassigned | unassigned
strategy String
reason String?
actor String // system | agentId | adminId
createdAt DateTime @default(now())
}
```
## Domain: SLA
```prisma
model SLAPolicy {
id String @id @default(cuid())
name String
productId String?
categoryId String?
problemTypeId String?
priority String?
firstResponseMinutes Int
investigationMinutes Int?
resolutionMinutes Int
customerResponseMinutes Int?
businessCalendarId String?
active Boolean @default(true)
}
model SLARun {
id String @id @default(cuid())
ticketId String @unique
policyId String
firstResponseDueAt DateTime?
resolutionDueAt DateTime?
status String // running | paused | warning | breached | completed
pausedAt DateTime?
resumedAt DateTime?
breachedAt DateTime?
completedAt DateTime?
}
model BusinessCalendar {
id String @id @default(cuid())
name String
timezone String
workingHours Json
holidays Holiday[]
}
model Holiday {
id String @id @default(cuid())
calendarId String
calendar BusinessCalendar @relation(fields: [calendarId], references: [id])
date DateTime
description String?
}
```
## Domain: Escalation
```prisma
model EscalationPolicy {
id String @id @default(cuid())
name String
productId String?
active Boolean @default(true)
rules EscalationRule[]
}
model EscalationRule {
id String @id @default(cuid())
policyId String
policy EscalationPolicy @relation(fields: [policyId], references: [id])
triggerType String // first_response_breach | resolution_breach | inactivity |
// priority_increase | customer_escalation | repeated_reopen |
// manual | product_defect | dependency_timeout | critical_incident
condition Json
targetNodeId String
notify Json // who/how to notify
active Boolean @default(true)
}
model EscalationEvent {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
ruleId String?
fromNodeId String?
toNodeId String?
reason String
triggeredBy String // system | agentId | customer
createdAt DateTime @default(now())
}
```
## Domain: Problem Resolution
```prisma
model Investigation {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
investigator String
findings Json
evidence Json?
internalNotes String?
status String // open | complete
createdAt DateTime @default(now())
}
model RootCause {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
type String // technical | configuration | external_dependency | business | contributing_factor
description String
createdAt DateTime @default(now())
}
model Solution {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
proposed String
approved Boolean @default(false)
createdAt DateTime @default(now())
implementation SolutionImplementation?
verification SolutionVerification?
}
model SolutionImplementation {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
notes String?
implementedBy String
implementedAt DateTime @default(now())
}
model SolutionVerification {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
method String // automated | technical_test | customer_confirmation | agent_confirmation
result String // success | failed
evidence Json?
verifiedAt DateTime @default(now())
}
model Resolution {
id String @id @default(cuid())
ticketId String @unique
outcome String
resolvedBy String // "ai" | agentId
resolvedAt DateTime @default(now())
}
```
## Domain: AI Support
```prisma
model AISupportSession {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
status String // analyzing | troubleshooting | verifying | resolved | escalated
startedAt DateTime @default(now())
endedAt DateTime?
diagnoses AIDiagnosis[]
interactions AIInteraction[]
actions AIAction[]
knowledgeRefs AIKnowledgeReference[]
}
model AIDiagnosis {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
product String
feature String?
problemType String
severity String
confidence Float
possibleCauses String[]
createdAt DateTime @default(now())
}
model AIInteraction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
role String // customer | ai
content String
createdAt DateTime @default(now())
}
model AIRunbook {
id String @id @default(cuid())
key String // e.g. PDF_HTML_CONVERSION_FAILURE
productId String
steps Json // ordered, versioned step definitions
active Boolean @default(true)
}
model AIAction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
toolName String
input Json
riskLevel String
approvedBy String? // system-policy | agentId, when human approval required
createdAt DateTime @default(now())
result AIActionResult?
}
model AIActionResult {
id String @id @default(cuid())
actionId String @unique
action AIAction @relation(fields: [actionId], references: [id])
output Json
status String // success | failed
createdAt DateTime @default(now())
}
model AIKnowledgeReference {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
knowledgeId String
relevanceScore Float?
createdAt DateTime @default(now())
}
```
## Domain: Knowledge
```prisma
model KnowledgeEntry {
id String @id @default(cuid())
code String @unique // e.g. KB-DQ-102
productId String
product Product @relation(fields: [productId], references: [id])
feature String?
type String // known_issue | faq | resolution_procedure | operations
problem String?
symptoms String?
errorCode String?
cause String?
recommendedSolution String?
verificationSteps String?
escalationGuidance String?
version Int @default(1)
status String // draft | published | unpublished
effectiveDate DateTime?
categoryScope String[]
validationStatus String // unvalidated | validated
owner String?
lastReview DateTime?
source String?
createdAt DateTime @default(now())
}
model KnownIssue {
id String @id @default(cuid())
productId String
errorCodeId String?
description String
status String
}
model ErrorCode {
id String @id @default(cuid())
code String @unique // e.g. LAYOUT_PARSE_042
productId String
description String
}
model Runbook {
id String @id @default(cuid())
key String
productId String
product Product @relation(fields: [productId], references: [id])
steps Json
version Int @default(1)
active Boolean @default(true)
}
```
## Domain: Platform
```prisma
model Notification {
id String @id @default(cuid())
recipientRef String
channel String // in_app | email | push
event String
payload Json
status String // queued | sent | failed
createdAt DateTime @default(now())
}
model AuditLog {
id String @id @default(cuid())
actor String
actorType String // customer | agent | admin | system | ai
action String
entityType String
entityId String
oldValue Json?
newValue Json?
reason String?
metadata Json?
createdAt DateTime @default(now())
}
```
---
## Notes on modeling decisions
- **`Ticket` vs `Problem` are always separate tables** with a many-tickets-to-one-problem relationship, per [04](./04-ticketing-and-problem-management.md#2-problem-is-first-class--separate-from-ticket).
- **`Investigation`, `RootCause`, `Solution`, `SolutionVerification`, `Resolution` are five distinct models**, not one "resolution notes" text field — this is intentional per the spec and enables reporting on each stage independently.
- **`AuditLog` should be append-only** at the application layer: no update/delete code paths against this table, ever.
- **SLA timing must never be computed from `SLARun.createdAt` alone** — always resolve through the linked `BusinessCalendar`/`Holiday` records at read time or via a durable recompute job.
+229
View File
@@ -0,0 +1,229 @@
# 07 — Backend Architecture
## 1. Technology stack
- Node.js + TypeScript
- Fastify
- PostgreSQL + Prisma
- Redis + BullMQ
- Pino (structured logging)
- OpenAPI
- Zod (validation)
- Vitest
- Docker
**Architecture style:** modular monolith. Do not start with microservices — the module boundaries below make a future split possible, but premature service decomposition adds operational cost this system doesn't need yet.
## 2. Top-level source layout
```
src/
├── config
├── bootstrap
├── plugins
├── common
├── infrastructure
├── modules
├── events
├── jobs
└── api
```
## 3. Domain module groups
```
modules/
├── identity
│ ├── auth
│ ├── customers
│ ├── agents
│ └── teams
├── catalog
│ ├── products
│ ├── categories
│ ├── problem-types
│ └── priorities
├── ticketing
│ ├── tickets
│ ├── messages
│ └── attachments
├── problem-management
│ ├── problems
│ ├── investigation
│ ├── root-causes
│ ├── solutions
│ ├── verification
│ └── resolutions
├── ai-support
│ ├── agents
│ ├── sessions
│ ├── diagnosis
│ ├── knowledge
│ ├── troubleshooting
│ ├── tools
│ ├── tool-execution
│ ├── verification
│ └── escalation
├── orchestration
│ ├── hierarchy
│ ├── routing
│ ├── orchestration
│ ├── assignments
│ ├── sla
│ └── escalation
└── platform
├── notifications
├── audit
├── business-calendars
├── integrations
├── reports
└── admin
```
> Note: `identity` here means SupportHub's own agent/team/support-domain identity — **not** a re-implementation of SaaS user/tenant identity. See [01](./01-product-vision-and-principles.md#4-system-ownership-boundary) and [02](./02-integration-and-security.md#5-rbac-boundary).
## 4. Module internal structure
Standard module:
```
module/
├── controller/
├── routes/
├── schema/
├── repository/
├── service/
├── types/
├── mapper/
├── constants/
└── index.ts
```
Complex modules (real decision logic, not just CRUD) add:
```
├── engine/
├── rules/
├── strategies/
└── calculators/
```
Example — `orchestration`:
```
orchestration/
├── controller
├── routes
├── schema
├── repository
├── service
├── engine
├── rules
├── types
├── mapper
├── constants
└── index.ts
```
`ai-support` and `orchestration` are the two module groups most likely to need the extended structure across nearly all their submodules — see [03](./03-ai-support-architecture.md) and [05](./05-orchestration-sla-escalation.md).
## 5. Request flow
```
HTTP → Route → Schema validation → Controller → Service
→ Engine/Rules (if required) → Repository → Prisma → PostgreSQL
```
Rules:
- **Controller never touches Prisma directly.**
- **Repository** is the only layer that talks to Prisma.
- **Service** coordinates business workflows and calls repositories/engines.
- **Engine** implements complex decision logic (assignment strategy selection, SLA calculation, escalation rule evaluation, AI tool-execution policy).
## 6. Module boundaries
Modules must not reach into another module's internals. Only import through the target module's public `index.ts`.
```ts
// Allowed
import { TicketService } from "@/modules/ticketing/tickets";
// Not allowed — reaches past the module boundary
import { TicketRepository } from "@/modules/ticketing/tickets/repository/ticket.repository";
```
## 7. Redis / BullMQ usage
Redis is **not** a source of truth — it's for queues, caching, and coordination. Use BullMQ jobs for:
- SLA monitoring (warning, breach detection)
- Escalation triggering
- Notifications
- AI background work where appropriate (long-running tool calls, batch re-diagnosis)
- Analytics rollups
- Attachment processing (malware scan, thumbnailing)
- Cleanup jobs
**Jobs must be durable and idempotent.** Never use `setTimeout` or any in-memory timer for production SLA enforcement — a process restart must not lose or double-fire SLA state transitions.
## 8. Domain events
Emit and consume these as first-class domain events (not just side effects buried in service code), so audit, notifications, analytics, and SLA/escalation subsystems can all react independently:
```
TicketCreated, ProblemCreated, TicketClassified
AIAnalysisStarted, KnowledgeRetrieved, AIDiagnosisCompleted
AIActionRequested, AIActionCompleted, AITroubleshootingStarted
AIResolutionVerified, TicketEscalatedToHuman
TicketAssigned, TicketReassigned, PriorityChanged
SLANearingBreach, SLABreached
InvestigationStarted, RootCauseIdentified
SolutionProposed, SolutionImplemented, VerificationCompleted
TicketResolved, TicketClosed, TicketReopened
```
Events must be traceable (correlation ID) and auditable.
## 9. Notifications
Channels: in-app, email, push (where appropriate). Triggered asynchronously (via job queue, never inline in the request path) on: ticket created, AI solved, human escalation, assignment, agent reply, SLA warning, SLA breach, escalation, resolution, closure, reopen.
## 10. Audit
Audit every important action: ticket creation, AI escalation, assignment/reassignment, priority changes, hierarchy changes, SLA changes, escalation, investigation, root cause, solution, verification, resolution, closure.
Audit record shape:
```ts
interface AuditRecord {
actor: string;
actorType: "customer" | "agent" | "admin" | "system" | "ai";
action: string;
entity: string;
entityId: string;
oldValue?: unknown;
newValue?: unknown;
timestamp: Date;
reason?: string;
metadata?: Record<string, unknown>;
}
```
Security-relevant audit records should be **append-only** from the application's perspective — no service-layer update/delete path against `AuditLog`.
## 11. Observability endpoints
```
GET /health
GET /health/live
GET /health/ready
GET /metrics
```
Structured logging via Pino, with request ID and correlation ID on every log line; metrics and tracing wired through from day one, not bolted on later. Full metric list is in [09](./09-testing-observability-cicd.md#observability).
+148
View File
@@ -0,0 +1,148 @@
# 08 — Frontend Architecture
## 1. Technology stack
Next.js (App Router) · TypeScript · Tailwind CSS · shadcn/ui-style components · Lucide icons · TanStack Query · Zustand (where needed) · React Hook Form · Zod · Playwright · Vitest
## 2. Frontend source structure
```
src/app
src/features
src/components
src/lib
src/hooks
src/stores
src/providers
src/types
src/constants
src/theme
src/styles
```
Route groups:
```
(customer)
(support)
(admin)
(public)
```
Feature modules are self-contained:
```
features/tickets/
├── api
├── components
├── hooks
├── schemas
├── types
├── constants
└── index.ts
```
**Do not create giant global business components** — logic and UI for a feature live inside that feature's folder.
## 3. Customer frontend
Lives inside the SaaS/product context — the customer never leaves their product to get support.
**Support Center** is the main surface, with areas:
- AI Support
- My Cases / Tickets
- Support History
- Optional resources
Primary journey:
```
Problem → AI analysis → Knowledge found → Guided steps → Customer action
→ Verification → Resolved
(if unresolved) → Human Support
```
### Support Center home
Communicates: *"How can we help you?"* The customer can describe a problem, start AI support, view existing cases, or see an active case. A case/ticket card shows: case ID, problem, product, current stage, progress, AI status, and human-support status when applicable.
### AI Support UI — not a ChatGPT clone
The interface must visibly communicate each stage, using clear cards/progress indicators, not just a scrolling chat log:
1. Problem understanding
2. Product analysis
3. Knowledge search
4. Diagnosis
5. Recommended solution
6. Guided steps
7. Customer action
8. Verification
9. Resolve or escalate
Example card content:
```
AI analyzed: PDF conversion failure
Problem detected: LAYOUT_PARSE_042
Likely cause: Layout parser failure
Recommended: Enable fallback parser
Guided steps:
1. Open Conversion Settings
2. Open Advanced
3. Enable fallback parser
4. Retry conversion
5. Verify output
[ I completed this step ] [ I need help ] [ Problem solved ] [ Not solved ]
```
### What the customer never sees
Internal support hierarchy, internal assignment algorithms, agent workload, internal escalation rules, internal notes, internal routing logic.
## 4. Support agent frontend (internal)
A distinct experience from the customer app. Main areas:
- Dashboard
- My Queue
- All Tickets
- Problems
- SLA Monitoring
- Escalations
- Knowledge
- Reports
**Agent ticket workspace** shows: customer, tenant, product, problem, AI summary, diagnosis, troubleshooting history, conversation, investigation, root cause, solution, verification, resolution, SLA, assignment, escalation history.
**Critical UX requirement:** the agent continues from the AI's context — they never restart diagnosis from zero. The AI hand-off summary (see [03](./03-ai-support-architecture.md#10-escalation-trigger-conditions-ai--human)) should be the first thing the agent reads.
## 5. Admin frontend
Configuration surface. Areas: Products, Categories, Problem Types, Priorities, Support Hierarchy, Teams, Agents, Skills, Routing Rules, Assignment Rules, SLA Policies, Escalation Policies, Knowledge, Runbooks, Reports, Audit, Settings.
**None of these configuration values may be hardcoded** in frontend or backend source — the admin UI is how the business actually changes behavior.
## 6. Real-time updates
Use WebSocket or SSE so the customer/agent sees updates without refreshing:
- AI analyzing
- AI found knowledge
- AI generated solution
- AI waiting for customer
- Ticket assigned
- Agent replied
- SLA warning / SLA breached
- Escalation
- Ticket resolved
## 7. UX principle recap by persona
| Persona | Principle |
|---|---|
| Customer | Simple, guided, trustworthy, product-aware |
| Agent | Information-dense, fast, operational, context-rich |
| Admin | Configurable, visual, rule-driven, auditable |
+97
View File
@@ -0,0 +1,97 @@
# 09 — Testing, Observability & CI/CD
## 1. Testing strategy
### Backend
- Unit tests
- Integration tests
- E2E tests
- Concurrency tests (assignment race conditions — two tickets assigned simultaneously must never corrupt round-robin state or double-assign)
- SLA tests (pause/resume correctness, business-calendar math, durability across a simulated process restart)
- Escalation idempotency tests (a rule firing twice must not create duplicate escalation events)
- Orchestration tests (capability matching, hierarchy traversal, strategy selection)
- AI tool permission tests (the AI must never be able to invoke a tool it isn't scoped for; high-risk tools must require policy/approval regardless of AI confidence)
### Frontend
- Unit tests
- Integration tests
- Playwright E2E, covering:
- AI support flow
- Customer escalation flow
- Agent flow
- Admin configuration flow
### Critical end-to-end scenarios (must both exist as automated tests)
**Scenario A — AI resolves directly:**
```
Customer → Product → Support → Problem → AI → Knowledge
→ Guided troubleshooting → Verification → AI resolved
```
**Scenario B — AI escalates to human:**
```
Customer → Problem → AI → troubleshooting failed → human escalation
→ orchestration → assignment → SLA → investigation → solution
→ verification → resolution → closure
```
## 2. Observability
### Logging
Pino structured logging across the backend, with a request ID and correlation ID attached to every log line so a single ticket's full journey (AI session → tool calls → escalation → assignment → SLA events) can be traced end to end.
### Metrics & tracing
Wire metrics and tracing in from the start, not retrofitted. Expose:
```
GET /health
GET /health/live
GET /health/ready
GET /metrics
```
### Key metrics to track
- AI resolution rate
- AI escalation rate
- Human resolution rate
- Average resolution time
- First response time
- SLA compliance
- Escalation rate
- Recurring problems
- Most common errors
- Knowledge effectiveness
- Tool failure rate
### Reporting dashboards
| Dashboard | Contents |
|---|---|
| **Management** | Total cases, AI resolved, human escalated, resolved, open, SLA compliance, SLA breaches, escalation count, average response, average resolution |
| **Product** | Support volume by product, problem types, recurring problems, AI resolution rate, human escalation rate, top errors |
| **Support** | Workload, agent assignments, SLA risk, escalations, response performance, resolution performance |
| **AI** | AI resolution rate, failed troubleshooting, knowledge match rate, confidence distribution, tool success/failure, human handoff rate |
## 3. CI/CD (Jenkins)
Repository includes a `Jenkinsfile` implementing:
```
Checkout
→ Install
→ Environment validation
→ Typecheck
→ Lint
→ Format check
→ Unit test
→ Integration test
→ E2E test
→ Build
→ Docker build
→ Publish
→ Deploy
```
Production deployments use **protected Jenkins credentials/environment variables** — real secrets are never committed to the repository (see [02](./02-integration-and-security.md#7-environment--secrets-handling)).
+82
View File
@@ -0,0 +1,82 @@
# 10 — Implementation Roadmap
Implement incrementally. **Do not implement all business logic in one step.** Each phase below must be typed, tested, documented, integrated, observable, and production-safe before moving on — a feature isn't "done" until it clears all seven of those, not just "coded."
## Phased plan
| Phase | Focus | Primary deliverables |
|---|---|---|
| **1** | Engineering foundation | Repo scaffolding, Fastify modular-monolith skeleton, Next.js app skeleton, Prisma schema baseline, CI pipeline skeleton, env validation, health endpoints |
| **2** | SaaS integration | Product/ProductIntegration models, credential validation, service-to-service auth (signed tokens/OAuth2/mTLS), inbound request contract, rate limiting |
| **3** | Product knowledge | Knowledge/KnownIssue/ErrorCode/Runbook models, admin CRUD, versioning + publish state, retrieval (RAG) layer |
| **4** | AI support | AI session/diagnosis/interaction models, classification, RAG-backed reasoning, confidence thresholds (configurable), tool system with permission/risk gating, runbook engine, verification logic |
| **5** | Ticketing | Ticket + Problem models (kept separate), message types, attachment pipeline (object storage, scanning, expiring URLs), ticket lifecycle state machine |
| **6** | Support organization | Team/Agent/AgentSkill/AgentAvailability models, dynamic HierarchyNode configuration, admin hierarchy editor |
| **7** | Orchestration and assignment | Orchestration engine, capability matching, pluggable assignment strategies, concurrency-safe round robin, assignment history |
| **8** | SLA and escalation | SLA policy engine, business calendar/holiday support, durable pause/resume via BullMQ, rule-driven escalation engine, escalation event audit |
| **9** | Problem resolution | Investigation/RootCause/Solution/SolutionImplementation/SolutionVerification/Resolution models and workflows, customer confirmation + reopen flow |
| **10** | Agent/Admin UI | Agent workspace (continues from AI context), admin configuration surfaces for every configurable subsystem above |
| **11** | Analytics, hardening, security, production deployment | Reporting dashboards, full observability, security hardening pass, load/concurrency testing, production deployment pipeline |
> Note the dependency direction: Phase 4 (AI) and Phase 9 (resolution stages) both plug into Phase 5's ticket, so ticketing's core data model should be stable before AI or resolution logic is built against it — even though ticketing is listed after AI support here, expect to iterate the `Ticket`/`Problem` shape lightly across phases 49 rather than treating phase 5 as strictly sequential.
## Success criteria checklist
Use this as the actual go/no-go list, not phase names — a phase can be "complete" on paper while missing several of these.
- [ ] A SaaS product can securely integrate with SupportHub
- [ ] The SaaS user can enter support from within the product
- [ ] SupportHub receives trusted product/tenant/user context
- [ ] A problem creates a durable support case immediately
- [ ] AI understands the problem
- [ ] AI retrieves correct product knowledge
- [ ] AI can diagnose known issues
- [ ] AI can guide the customer through supported troubleshooting
- [ ] The system verifies successful resolution with evidence
- [ ] AI can resolve supported issues automatically
- [ ] Unresolved issues are escalated automatically
- [ ] AI context is preserved in the ticket for the human agent
- [ ] Orchestration chooses the correct support path
- [ ] Capability/skill matching works correctly
- [ ] Assignment respects availability/workload
- [ ] SLA is applied correctly (calendar-aware, durable)
- [ ] Escalation occurs according to configured policy
- [ ] Human agents receive full context, don't restart diagnosis
- [ ] Agents can investigate and resolve the problem
- [ ] Resolution is verified and recorded
- [ ] Customer sees the final result
- [ ] Complete audit history exists
- [ ] All critical operations are observable
- [ ] CI/CD can validate and deploy the system safely
## Open business decisions (do not invent final values)
Anything the business hasn't finalized yet must be explicitly marked in code, config schema, and documentation as one of:
```
CONFIGURABLE
OPEN BUSINESS DECISION
REQUIRES BUSINESS CONFIRMATION
```
Known candidates for this list at spec time:
- Actual SLA minute values per product/priority/category
- Actual escalation rule conditions and target nodes per product
- Whether/when premium support tiers override "support enabled by default"
- Confidence-threshold cut points for high/medium/low AI bands, per product
- Assignment strategy choice per hierarchy node
- Business calendar definitions (hours, holidays, timezones) per team
Never hardcode a placeholder value for any of the above and ship it as if it were final — mark it and surface it for confirmation instead.
## Cross-reference map
| If you're building... | Read |
|---|---|
| The overall model and boundaries | [01](./01-product-vision-and-principles.md), [02](./02-integration-and-security.md) |
| The AI agent | [03](./03-ai-support-architecture.md) |
| The ticket/problem data model | [04](./04-ticketing-and-problem-management.md), [06](./06-database-schema.md) |
| Orchestration/SLA/escalation | [05](./05-orchestration-sla-escalation.md) |
| Backend module layout | [07](./07-backend-architecture.md) |
| Any UI surface | [08](./08-frontend-architecture.md) |
| Tests, CI, dashboards | [09](./09-testing-observability-cicd.md) |
@@ -0,0 +1,88 @@
# 11 — Architect's Additions: Gaps & Recommendations
Everything in files 0010 is a direct organization of the original specification. **Everything below is added by me** — things a production enterprise support platform needs that the original spec didn't call out, or only mentioned in passing. I've grouped them by how costly they are to bolt on later.
---
## A. Critical — expensive to retrofit, cheap to design in now
### A1. Idempotency on ticket creation
The spec defines the inbound product→SupportHub request but never addresses **retries**. If DocuQube's client times out waiting for a response and retries the same "PDF conversion failed" report, you'll get duplicate tickets for one problem unless the caller sends an idempotency key.
- Add `idempotencyKey` (client-generated, e.g. hash of `productId + referenceIds + timestamp-bucket`) to the inbound contract in [02](./02-integration-and-security.md).
- Store it on `Ticket` with a unique constraint scoped to `productId`; a repeat request within a configurable window returns the existing ticket instead of creating a new one.
### A2. Bi-directional integration (SupportHub → product callbacks)
The spec only defines product → SupportHub. But the product's own UI (e.g., DocuQube's "Help & Support" widget) needs to know the ticket's status without polling. Add:
- A registered **webhook URL per `ProductIntegration`**, signed the same way inbound requests are (HMAC or mTLS), firing on key events: `ticket.status_changed`, `ticket.resolved`, `ticket.escalated`.
- Delivery must be async (via BullMQ), retried with backoff, and logged — a failed webhook delivery should never block or roll back the underlying ticket state change.
### A3. Multi-tenant data isolation enforced at the data layer, not just the app layer
Section 52 says "a customer must never access another customer's tickets," but the spec only implies application-level scoping. For an enterprise platform, add:
- **Postgres Row-Level Security (RLS)** policies on `Ticket`, `TicketMessage`, `TicketAttachment` keyed on `externalTenantId`, so a bug in one service-layer query can't leak cross-tenant data. Application-level scoping remains the primary control; RLS is the belt-and-suspenders layer.
### A4. AI prompt-injection defense
The spec covers AI tool permissioning and hallucination control (section 58) but not **adversarial customer input**. A customer's problem description, or content inside an uploaded attachment (e.g., a PDF with embedded text), is untrusted input that reaches the LLM. Add explicit handling:
- Treat retrieved knowledge, customer messages, and any attachment-derived text as **data, not instructions** — the system prompt must state this and the orchestration layer should never let content from these sources alter tool permissions or escalation policy.
- Tool-invocation requests coming out of the model are validated against the deterministic policy layer regardless of what the model claims justifies them (already implied by section 58/11, but worth stating as an explicit adversarial-input test case in [09](./09-testing-observability-cicd.md)).
### A5. Optimistic concurrency on mutable shared state
`Ticket.status`, `SLARun.status`, and `AgentAvailability.currentLoad` are all written by multiple actors (customer actions, AI, agents, background jobs) concurrently. Add a `version` column (optimistic locking) to these three tables specifically, on top of the general concurrency guidance already in [06](./06-database-schema.md) — a plain "last write wins" update is not sufficient for SLA/assignment correctness under load.
---
## B. Important — real gaps, moderate cost to retrofit
### B1. RAG implementation specifics
The spec says "use a RAG architecture" and "vector database as appropriate" but leaves the actual retrieval design open. Decide and document:
- Embedding model + chunking strategy per knowledge entry type (a `KnowledgeEntry` with distinct `problem/symptoms/cause/solution` fields probably wants field-aware chunking, not one blob embedding).
- Retrieval filters must apply **before** the vector search (product scope, status=published, validationStatus) — never filter after, or you'll retrieve fewer results than the limit implies.
- Re-ranking step before knowledge reaches the LLM context, prioritizing `validationStatus: validated` and recency.
### B2. AI cost and token governance
Nothing in the spec addresses LLM cost/latency control at scale. Add:
- Per-session token budget and a hard step-count cap on the reasoning loop (diagnosis → tool call → re-diagnosis) to prevent runaway sessions.
- Model routing/fallback (e.g., a smaller/faster model for classification, a stronger one for diagnosis) as a configurable policy, not a hardcoded model name.
- Track cost per ticket as a reportable metric alongside the AI dashboard metrics in [09](./09-testing-observability-cicd.md).
### B3. Knowledge effectiveness feedback loop
The spec has a "knowledge effectiveness" metric (section 60) but no mechanism to actually compute it. Add:
- Link `AIKnowledgeReference` → ticket outcome (`AI_RESOLVED` vs `HUMAN_ESCALATION`) so each knowledge entry accumulates a resolution-contribution rate.
- Surface low-performing knowledge entries to admins for review — this closes the loop the spec's admin knowledge management (section 59) otherwise leaves open-ended.
### B4. Customer satisfaction (CSAT) capture
Not in the original spec at all. Add a lightweight, optional post-resolution prompt ("Was this helpful?") captured against the ticket, reportable per product/agent/AI — this is standard for any support platform and materially informs whether "AI resolved" actually meant the customer was satisfied, not just that verification evidence existed.
### B5. Data retention, deletion, and PII handling
Section 52 covers security but not data lifecycle. For an enterprise platform touching customer data across many tenants, define explicitly (as an `OPEN BUSINESS DECISION` per [10](./10-implementation-roadmap.md)):
- Retention period for tickets/messages/attachments/AI session transcripts.
- A deletion path when the SaaS reports a user/tenant deletion (SupportHub must purge or anonymize its `externalUserId`-linked records — it can't wait indefinitely holding data the SaaS no longer has consent for).
- Whether attachments or AI transcripts may contain PII that needs redaction before being used as RAG training/eval data.
### B6. API versioning and error contract
The spec mentions OpenAPI but not a versioning scheme or a standard error shape. Add:
- URL or header-based versioning (`/v1/...`) from day one — retrofitting this after external product integrations exist is painful.
- A single error envelope (`{ error: { code, message, requestId, details? } }`) used by every endpoint, so integrating products write one error handler, not one per endpoint.
### B7. Localization / multi-language customer input
Customers may report problems in a language other than the knowledge base's authoring language. Not addressed anywhere in the spec. At minimum, decide: does the AI reason and search knowledge in English regardless of input language and translate the response back, or is knowledge itself multi-language? This affects the RAG design in B1 and should be an explicit early decision, not discovered mid-build.
---
## C. Worth deciding early, lower urgency
- **Feature flags** for gradual AI capability rollout per product (e.g., enable tool execution for DocuQube before enabling it for a newer, less-tested product).
- **Full-text/ticket search** for agents (section 38's "All Tickets" view will need this quickly) — Postgres full-text search is likely sufficient before reaching for a separate search engine.
- **Bulk admin operations** (bulk reassign on agent offboarding, bulk close on stale tickets) — not mentioned, but every real deployment needs it within the first quarter.
- **Disaster recovery / backup cadence and RPO/RTO targets** — absent from the spec's otherwise thorough operations coverage.
- **Incident-management integration** (e.g., paging on `SLABreached` for critical severity) — the spec defines the breach event but not what happens operationally when one fires outside business hours.
- **Sandbox integration environment** — a product team integrating with SupportHub needs a way to test the full inbound/outbound contract without touching production tenants; worth a dedicated `environment: sandbox` flag on `ProductIntegration`.
---
## What I did *not* add
I deliberately didn't invent: specific SLA minute values, specific confidence thresholds, specific retention periods, or specific vector database/embedding model choices — those are exactly the kind of "final business/technical policy values" the original spec (and [10](./10-implementation-roadmap.md)) says must come from the business/team, not be guessed. Where I raised something above that implies a concrete choice, treat it as `REQUIRES BUSINESS CONFIRMATION` or `OPEN BUSINESS DECISION`, consistent with the rest of this guide.
+978 -35
View File
File diff suppressed because it is too large Load Diff
+15 -3
View File
@@ -22,7 +22,7 @@
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.ts\" \"prisma/**/*.ts\"",
"test": "npm run test:unit",
"test:unit": "vitest run",
"test:unit": "vitest run tests/unit",
"test:watch": "vitest",
"test:env": "vitest run --env-file=.env.test",
"test:integration": "vitest run tests/integration",
@@ -45,6 +45,7 @@
"docker:build:prod": "docker compose --env-file .env.prod -f docker-compose.prod.yml build"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.123.0",
"@aws-sdk/client-s3": "^3.556.0",
"@aws-sdk/s3-request-presigner": "^3.556.0",
"@fastify/cors": "^9.0.1",
@@ -53,22 +54,33 @@
"@fastify/swagger": "^8.14.0",
"@fastify/swagger-ui": "^3.0.0",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-base": "^1.22.0",
"@opentelemetry/context-async-hooks": "^2.11.0",
"@opentelemetry/exporter-trace-otlp-http": "^0.222.0",
"@opentelemetry/resources": "^2.11.0",
"@opentelemetry/sdk-trace-base": "^2.11.0",
"@prisma/client": "^5.12.1",
"bcryptjs": "^3.0.3",
"bullmq": "^5.7.1",
"dotenv": "^16.4.5",
"fastify": "^4.26.2",
"fastify-plugin": "^4.5.1",
"ioredis": "^5.3.2",
"jsonwebtoken": "^9.0.3",
"luxon": "^3.7.2",
"pino": "^8.20.0",
"pino-pretty": "^11.0.0",
"prom-client": "^15.1.1",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/autocannon": "^7.12.7",
"@types/bcryptjs": "^2.4.6",
"@types/jsonwebtoken": "^9.0.10",
"@types/luxon": "^3.7.5",
"@types/node": "^20.12.7",
"@typescript-eslint/eslint-plugin": "^7.6.0",
"@typescript-eslint/parser": "^7.6.0",
"autocannon": "^8.0.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0",
"husky": "^9.0.11",
@@ -76,7 +88,7 @@
"prettier": "^3.2.5",
"prisma": "^5.12.1",
"tsc-alias": "^1.9.2",
"tsx": "^4.7.2",
"tsx": "^4.23.13",
"typescript": "^5.4.5",
"vitest": "^1.5.0"
},
@@ -0,0 +1,103 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('ADMIN', 'AGENT', 'CUSTOMER');
-- CreateTable
CREATE TABLE "users" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" "UserRole" NOT NULL DEFAULT 'CUSTOMER',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "products" (
"id" TEXT NOT NULL,
"externalProductId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"supportEnabled" BOOLEAN NOT NULL DEFAULT true,
"status" TEXT NOT NULL DEFAULT 'active',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "products_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "product_integrations" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"credentialRef" TEXT NOT NULL,
"previousCredentialRef" TEXT,
"previousCredentialExpiresAt" TIMESTAMP(3),
"authMechanism" TEXT NOT NULL DEFAULT 'signed_token',
"allowedScope" JSONB NOT NULL,
"rateLimitPerMinute" INTEGER NOT NULL DEFAULT 60,
"rateLimitPerUserPerMinute" INTEGER NOT NULL DEFAULT 20,
"status" TEXT NOT NULL DEFAULT 'active',
"rotatedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "product_integrations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "customer_references" (
"id" TEXT NOT NULL,
"externalUserId" TEXT NOT NULL,
"externalTenantId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "customer_references_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "categories" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "categories_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "audit_logs" (
"id" TEXT NOT NULL,
"actor" TEXT NOT NULL,
"actorType" TEXT NOT NULL,
"action" TEXT NOT NULL,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"oldValue" JSONB,
"newValue" JSONB,
"reason" TEXT,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "products_externalProductId_key" ON "products"("externalProductId");
-- CreateIndex
CREATE UNIQUE INDEX "product_integrations_productId_key" ON "product_integrations"("productId");
-- CreateIndex
CREATE UNIQUE INDEX "customer_references_externalUserId_externalTenantId_key" ON "customer_references"("externalUserId", "externalTenantId");
-- AddForeignKey
ALTER TABLE "product_integrations" ADD CONSTRAINT "product_integrations_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "categories" ADD CONSTRAINT "categories_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,104 @@
-- CreateTable
CREATE TABLE "problems" (
"id" TEXT NOT NULL,
"statement" TEXT NOT NULL,
"symptoms" TEXT NOT NULL,
"impact" TEXT,
"productId" TEXT NOT NULL,
"categoryId" TEXT,
"severity" TEXT NOT NULL,
"customerImpact" TEXT,
"businessImpact" TEXT,
"environment" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "problems_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "tickets" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"customerId" TEXT NOT NULL,
"externalUserId" TEXT NOT NULL,
"externalTenantId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'NEW',
"priority" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"categoryId" TEXT,
"idempotencyKey" TEXT,
"version" INTEGER NOT NULL DEFAULT 1,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "tickets_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_messages" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"authorRef" TEXT NOT NULL,
"body" TEXT NOT NULL,
"visibleToCustomer" BOOLEAN NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_messages_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ticket_attachments" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"storageKey" TEXT NOT NULL,
"fileName" TEXT NOT NULL,
"mimeType" TEXT NOT NULL,
"sizeBytes" INTEGER NOT NULL,
"scanStatus" TEXT NOT NULL DEFAULT 'pending',
"uploadedBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ticket_attachments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "tickets_code_key" ON "tickets"("code");
-- CreateIndex
CREATE INDEX "tickets_productId_status_idx" ON "tickets"("productId", "status");
-- CreateIndex
CREATE INDEX "tickets_externalTenantId_externalUserId_idx" ON "tickets"("externalTenantId", "externalUserId");
-- CreateIndex
CREATE UNIQUE INDEX "tickets_productId_idempotencyKey_key" ON "tickets"("productId", "idempotencyKey");
-- CreateIndex
CREATE INDEX "ticket_messages_ticketId_visibleToCustomer_createdAt_idx" ON "ticket_messages"("ticketId", "visibleToCustomer", "createdAt");
-- AddForeignKey
ALTER TABLE "problems" ADD CONSTRAINT "problems_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "problems" ADD CONSTRAINT "problems_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customer_references"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_messages" ADD CONSTRAINT "ticket_messages_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ticket_attachments" ADD CONSTRAINT "ticket_attachments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,91 @@
-- CreateTable
CREATE TABLE "knowledge_entries" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"isCurrentVersion" BOOLEAN NOT NULL DEFAULT true,
"productId" TEXT NOT NULL,
"feature" TEXT,
"type" TEXT NOT NULL,
"problem" TEXT,
"symptoms" TEXT,
"errorCode" TEXT,
"cause" TEXT,
"recommendedSolution" TEXT,
"verificationSteps" TEXT,
"escalationGuidance" TEXT,
"status" TEXT NOT NULL DEFAULT 'draft',
"effectiveDate" TIMESTAMP(3),
"categoryScope" TEXT[],
"validationStatus" TEXT NOT NULL DEFAULT 'unvalidated',
"owner" TEXT,
"lastReview" TIMESTAMP(3),
"source" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "knowledge_entries_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "error_codes" (
"id" TEXT NOT NULL,
"code" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"description" TEXT NOT NULL,
CONSTRAINT "error_codes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "known_issues" (
"id" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"errorCodeId" TEXT,
"description" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'open',
CONSTRAINT "known_issues_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "runbooks" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"version" INTEGER NOT NULL DEFAULT 1,
"isCurrentVersion" BOOLEAN NOT NULL DEFAULT true,
"productId" TEXT NOT NULL,
"steps" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "runbooks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "knowledge_entries_productId_isCurrentVersion_status_effecti_idx" ON "knowledge_entries"("productId", "isCurrentVersion", "status", "effectiveDate");
-- CreateIndex
CREATE UNIQUE INDEX "knowledge_entries_code_version_key" ON "knowledge_entries"("code", "version");
-- CreateIndex
CREATE UNIQUE INDEX "error_codes_productId_code_key" ON "error_codes"("productId", "code");
-- CreateIndex
CREATE INDEX "runbooks_productId_key_isCurrentVersion_active_idx" ON "runbooks"("productId", "key", "isCurrentVersion", "active");
-- CreateIndex
CREATE UNIQUE INDEX "runbooks_key_productId_version_key" ON "runbooks"("key", "productId", "version");
-- AddForeignKey
ALTER TABLE "knowledge_entries" ADD CONSTRAINT "knowledge_entries_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "error_codes" ADD CONSTRAINT "error_codes_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "known_issues" ADD CONSTRAINT "known_issues_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "known_issues" ADD CONSTRAINT "known_issues_errorCodeId_fkey" FOREIGN KEY ("errorCodeId") REFERENCES "error_codes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "runbooks" ADD CONSTRAINT "runbooks_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,132 @@
-- CreateTable
CREATE TABLE "ai_support_sessions" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"activeRunbookKey" TEXT,
"currentStepIndex" INTEGER,
"clarifyingQuestionsAsked" INTEGER NOT NULL DEFAULT 0,
"toolCallCount" INTEGER NOT NULL DEFAULT 0,
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"endedAt" TIMESTAMP(3),
CONSTRAINT "ai_support_sessions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_diagnoses" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"product" TEXT NOT NULL,
"feature" TEXT,
"problemType" TEXT NOT NULL,
"severity" TEXT NOT NULL,
"confidence" DOUBLE PRECISION NOT NULL,
"possibleCauses" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_diagnoses_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_interactions" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_interactions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_actions" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"toolName" TEXT NOT NULL,
"input" JSONB NOT NULL,
"riskLevel" TEXT NOT NULL,
"evaluationOutcome" TEXT NOT NULL,
"refusalReason" TEXT,
"approvedBy" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_actions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_action_results" (
"id" TEXT NOT NULL,
"actionId" TEXT NOT NULL,
"output" JSONB NOT NULL,
"status" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_action_results_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_knowledge_references" (
"id" TEXT NOT NULL,
"sessionId" TEXT NOT NULL,
"knowledgeId" TEXT NOT NULL,
"relevanceScore" DOUBLE PRECISION,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ai_knowledge_references_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ai_confidence_policies" (
"id" TEXT NOT NULL,
"productId" TEXT,
"categoryId" TEXT,
"highThreshold" DOUBLE PRECISION NOT NULL,
"lowThreshold" DOUBLE PRECISION NOT NULL,
"maxClarifyingQuestions" INTEGER NOT NULL,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "ai_confidence_policies_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ai_support_sessions_ticketId_status_idx" ON "ai_support_sessions"("ticketId", "status");
-- CreateIndex
CREATE INDEX "ai_diagnoses_sessionId_idx" ON "ai_diagnoses"("sessionId");
-- CreateIndex
CREATE INDEX "ai_interactions_sessionId_createdAt_idx" ON "ai_interactions"("sessionId", "createdAt");
-- CreateIndex
CREATE INDEX "ai_actions_sessionId_createdAt_idx" ON "ai_actions"("sessionId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "ai_action_results_actionId_key" ON "ai_action_results"("actionId");
-- CreateIndex
CREATE INDEX "ai_knowledge_references_sessionId_idx" ON "ai_knowledge_references"("sessionId");
-- CreateIndex
CREATE UNIQUE INDEX "ai_confidence_policies_productId_categoryId_key" ON "ai_confidence_policies"("productId", "categoryId");
-- AddForeignKey
ALTER TABLE "ai_support_sessions" ADD CONSTRAINT "ai_support_sessions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_diagnoses" ADD CONSTRAINT "ai_diagnoses_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_interactions" ADD CONSTRAINT "ai_interactions_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_actions" ADD CONSTRAINT "ai_actions_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_action_results" ADD CONSTRAINT "ai_action_results_actionId_fkey" FOREIGN KEY ("actionId") REFERENCES "ai_actions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_knowledge_references" ADD CONSTRAINT "ai_knowledge_references_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ai_support_sessions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ai_confidence_policies" ADD CONSTRAINT "ai_confidence_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,97 @@
-- CreateTable
CREATE TABLE "teams" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "teams_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agents" (
"id" TEXT NOT NULL,
"teamId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agents_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_skills" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"skillTag" TEXT NOT NULL,
"level" INTEGER NOT NULL,
CONSTRAINT "agent_skills_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "agent_availability" (
"id" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"status" TEXT NOT NULL,
"workingHours" JSONB NOT NULL,
"currentLoad" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "agent_availability_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "hierarchy_nodes" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"parentId" TEXT,
"order" INTEGER NOT NULL,
"teamId" TEXT,
"skills" TEXT[],
"productScope" TEXT[],
"categoryScope" TEXT[],
"priorityScope" TEXT[],
"assignmentStrategy" TEXT NOT NULL,
"slaPolicyId" TEXT,
"escalationPolicyId" TEXT,
"entryConditions" JSONB,
"exitConditions" JSONB,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "hierarchy_nodes_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "agents_teamId_active_idx" ON "agents"("teamId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "agent_skills_agentId_skillTag_key" ON "agent_skills"("agentId", "skillTag");
-- CreateIndex
CREATE UNIQUE INDEX "agent_availability_agentId_key" ON "agent_availability"("agentId");
-- CreateIndex
CREATE INDEX "hierarchy_nodes_parentId_order_idx" ON "hierarchy_nodes"("parentId", "order");
-- CreateIndex
CREATE INDEX "hierarchy_nodes_active_idx" ON "hierarchy_nodes"("active");
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_skills" ADD CONSTRAINT "agent_skills_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "agent_availability" ADD CONSTRAINT "agent_availability_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hierarchy_nodes" ADD CONSTRAINT "hierarchy_nodes_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "hierarchy_nodes"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "hierarchy_nodes" ADD CONSTRAINT "hierarchy_nodes_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,42 @@
-- CreateTable
CREATE TABLE "assignments" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"agentId" TEXT NOT NULL,
"strategy" TEXT NOT NULL,
"reason" TEXT,
"isCurrent" BOOLEAN NOT NULL DEFAULT true,
"assignedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"unassignedAt" TIMESTAMP(3),
CONSTRAINT "assignments_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "assignment_history" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"agentId" TEXT,
"action" TEXT NOT NULL,
"strategy" TEXT NOT NULL,
"reason" TEXT,
"actor" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "assignment_history_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "assignments_ticketId_isCurrent_idx" ON "assignments"("ticketId", "isCurrent");
-- CreateIndex
CREATE INDEX "assignment_history_ticketId_createdAt_idx" ON "assignment_history"("ticketId", "createdAt");
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignments" ADD CONSTRAINT "assignments_agentId_fkey" FOREIGN KEY ("agentId") REFERENCES "agents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "assignment_history" ADD CONSTRAINT "assignment_history_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,147 @@
-- CreateTable
CREATE TABLE "sla_policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productId" TEXT,
"categoryId" TEXT,
"problemTypeId" TEXT,
"priority" TEXT,
"firstResponseMinutes" INTEGER NOT NULL,
"investigationMinutes" INTEGER,
"resolutionMinutes" INTEGER NOT NULL,
"customerResponseMinutes" INTEGER,
"businessCalendarId" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "sla_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "sla_runs" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"firstResponseDueAt" TIMESTAMP(3),
"resolutionDueAt" TIMESTAMP(3),
"status" TEXT NOT NULL,
"pausedAt" TIMESTAMP(3),
"resumedAt" TIMESTAMP(3),
"breachedAt" TIMESTAMP(3),
"firstResponseBreachedAt" TIMESTAMP(3),
"completedAt" TIMESTAMP(3),
CONSTRAINT "sla_runs_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "business_calendars" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"timezone" TEXT NOT NULL,
"workingHours" JSONB NOT NULL,
CONSTRAINT "business_calendars_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "holidays" (
"id" TEXT NOT NULL,
"calendarId" TEXT NOT NULL,
"date" TIMESTAMP(3) NOT NULL,
"description" TEXT,
CONSTRAINT "holidays_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_policies" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"productId" TEXT,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "escalation_policies_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_rules" (
"id" TEXT NOT NULL,
"policyId" TEXT NOT NULL,
"triggerType" TEXT NOT NULL,
"condition" JSONB NOT NULL,
"targetNodeId" TEXT NOT NULL,
"notify" JSONB NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "escalation_rules_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "escalation_events" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"ruleId" TEXT,
"fromNodeId" TEXT,
"toNodeId" TEXT,
"reason" TEXT NOT NULL,
"triggeredBy" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "escalation_events_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "sla_policies_productId_categoryId_active_idx" ON "sla_policies"("productId", "categoryId", "active");
-- CreateIndex
CREATE UNIQUE INDEX "sla_runs_ticketId_key" ON "sla_runs"("ticketId");
-- CreateIndex
CREATE INDEX "sla_runs_status_resolutionDueAt_idx" ON "sla_runs"("status", "resolutionDueAt");
-- CreateIndex
CREATE INDEX "sla_runs_status_firstResponseDueAt_idx" ON "sla_runs"("status", "firstResponseDueAt");
-- CreateIndex
CREATE INDEX "holidays_calendarId_date_idx" ON "holidays"("calendarId", "date");
-- CreateIndex
CREATE INDEX "escalation_policies_productId_active_idx" ON "escalation_policies"("productId", "active");
-- CreateIndex
CREATE INDEX "escalation_rules_policyId_triggerType_active_idx" ON "escalation_rules"("policyId", "triggerType", "active");
-- CreateIndex
CREATE INDEX "escalation_events_ticketId_createdAt_idx" ON "escalation_events"("ticketId", "createdAt");
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "categories"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_policies" ADD CONSTRAINT "sla_policies_businessCalendarId_fkey" FOREIGN KEY ("businessCalendarId") REFERENCES "business_calendars"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "sla_runs" ADD CONSTRAINT "sla_runs_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "sla_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "holidays" ADD CONSTRAINT "holidays_calendarId_fkey" FOREIGN KEY ("calendarId") REFERENCES "business_calendars"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_policies" ADD CONSTRAINT "escalation_policies_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_policyId_fkey" FOREIGN KEY ("policyId") REFERENCES "escalation_policies"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_rules" ADD CONSTRAINT "escalation_rules_targetNodeId_fkey" FOREIGN KEY ("targetNodeId") REFERENCES "hierarchy_nodes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "escalation_events" ADD CONSTRAINT "escalation_events_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,105 @@
-- CreateTable
CREATE TABLE "investigations" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"investigator" TEXT NOT NULL,
"findings" JSONB NOT NULL,
"evidence" JSONB,
"internalNotes" TEXT,
"status" TEXT NOT NULL DEFAULT 'open',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "investigations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "root_causes" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"description" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "root_causes_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solutions" (
"id" TEXT NOT NULL,
"problemId" TEXT NOT NULL,
"proposed" TEXT NOT NULL,
"approved" BOOLEAN NOT NULL DEFAULT false,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solutions_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_implementations" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"notes" TEXT,
"implementedBy" TEXT NOT NULL,
"implementedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_implementations_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "solution_verifications" (
"id" TEXT NOT NULL,
"solutionId" TEXT NOT NULL,
"method" TEXT NOT NULL,
"result" TEXT NOT NULL,
"evidence" JSONB,
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "solution_verifications_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "resolutions" (
"id" TEXT NOT NULL,
"ticketId" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"resolvedBy" TEXT NOT NULL,
"resolvedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "resolutions_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "investigations_problemId_createdAt_idx" ON "investigations"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "root_causes_problemId_createdAt_idx" ON "root_causes"("problemId", "createdAt");
-- CreateIndex
CREATE INDEX "solutions_problemId_createdAt_idx" ON "solutions"("problemId", "createdAt");
-- CreateIndex
CREATE UNIQUE INDEX "solution_implementations_solutionId_key" ON "solution_implementations"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "solution_verifications_solutionId_key" ON "solution_verifications"("solutionId");
-- CreateIndex
CREATE UNIQUE INDEX "resolutions_ticketId_key" ON "resolutions"("ticketId");
-- AddForeignKey
ALTER TABLE "investigations" ADD CONSTRAINT "investigations_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "root_causes" ADD CONSTRAINT "root_causes_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solutions" ADD CONSTRAINT "solutions_problemId_fkey" FOREIGN KEY ("problemId") REFERENCES "problems"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_implementations" ADD CONSTRAINT "solution_implementations_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "solution_verifications" ADD CONSTRAINT "solution_verifications_solutionId_fkey" FOREIGN KEY ("solutionId") REFERENCES "solutions"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "resolutions" ADD CONSTRAINT "resolutions_ticketId_fkey" FOREIGN KEY ("ticketId") REFERENCES "tickets"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,20 @@
-- AlterTable
ALTER TABLE "agents" ADD COLUMN "userId" TEXT;
-- AlterTable
ALTER TABLE "users" ADD COLUMN "active" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN "passwordHash" TEXT NOT NULL DEFAULT '';
-- The default above exists only to satisfy the NOT NULL constraint against this (empty)
-- table at migration time — application code always provides a real bcryptjs hash on every
-- User row it creates (specs/010-identity-auth/data-model.md), so the default itself is
-- dropped immediately below to keep schema.prisma and the live database in agreement (no
-- default declared in the Prisma schema).
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP DEFAULT;
-- CreateIndex
CREATE UNIQUE INDEX "agents_userId_key" ON "agents"("userId");
-- AddForeignKey
ALTER TABLE "agents" ADD CONSTRAINT "agents_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "assignments_agentId_isCurrent_idx" ON "assignments"("agentId", "isCurrent");
@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "error_code_lookups" (
"id" TEXT NOT NULL,
"errorCodeId" TEXT NOT NULL,
"productId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "error_code_lookups_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "error_code_lookups_productId_createdAt_idx" ON "error_code_lookups"("productId", "createdAt");
-- AddForeignKey
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_errorCodeId_fkey" FOREIGN KEY ("errorCodeId") REFERENCES "error_codes"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "error_code_lookups" ADD CONSTRAINT "error_code_lookups_productId_fkey" FOREIGN KEY ("productId") REFERENCES "products"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- AlterTable
ALTER TABLE "sla_runs" ADD COLUMN "version" INTEGER NOT NULL DEFAULT 0;
-- 016-load-concurrency-testing research.md §1: at most one current assignment per ticket,
-- enforced at the database level (a partial unique index, since Prisma's schema DSL cannot
-- express a WHERE-predicated unique constraint directly).
CREATE UNIQUE INDEX "assignments_one_current_per_ticket" ON "assignments"("ticketId") WHERE "isCurrent" = true;
-- 016-load-concurrency-testing research.md §3: a given escalation rule may fire at most once
-- per ticket over that ticket's lifetime (SLARun.ticketId is already @unique — no reopen-cycle
-- support, so a rule-triggered breach genuinely cannot recur for the same ticket). Manual
-- escalations (rule_id IS NULL) are excluded and remain repeatable.
CREATE UNIQUE INDEX "escalation_events_ticket_rule_unique" ON "escalation_events"("ticketId", "ruleId") WHERE "ruleId" IS NOT NULL;
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
+714 -30
View File
@@ -13,39 +13,83 @@ enum UserRole {
CUSTOMER
}
enum ProductStatus {
ACTIVE
DEPRECATED
INACTIVE
}
model User {
id String @id @default(uuid())
email String @unique
name String
role UserRole @default(CUSTOMER)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(uuid())
email String @unique
name String
role UserRole @default(CUSTOMER)
passwordHash String // bcryptjs hash — never the plaintext password; see
// specs/010-identity-auth/data-model.md
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
auditLogs AuditLog[]
agent Agent?
@@map("users")
}
model Product {
id String @id @default(uuid())
code String @unique
name String
description String?
status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
externalProductId String @unique // reference into SaaS, not authoritative — see
// .specify/memory/constitution.md Principle I
name String
supportEnabled Boolean @default(true)
status String @default("active") // active | suspended | deprecated
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
categories Category[]
categories Category[]
integration ProductIntegration?
problems Problem[]
tickets Ticket[]
knowledgeEntries KnowledgeEntry[]
errorCodes ErrorCode[]
errorCodeLookups ErrorCodeLookup[]
knownIssues KnownIssue[]
runbooks Runbook[]
aiConfidencePolicies AIConfidencePolicy[]
slaPolicies SLAPolicy[]
escalationPolicies EscalationPolicy[]
@@map("products")
}
model ProductIntegration {
id String @id @default(cuid())
productId String @unique
product Product @relation(fields: [productId], references: [id])
// AES-256-GCM-encrypted secret (ciphertext, not plaintext) — see
// specs/002-saas-integration/research.md "Credential storage"
credentialRef String
previousCredentialRef String?
previousCredentialExpiresAt DateTime?
authMechanism String @default("signed_token") // free-text — not an enum,
// so a future integration can use oauth2_client_credentials or mtls without a migration
allowedScope Json // { tenantIds?: string[], allowAnyTenant?: boolean }
rateLimitPerMinute Int @default(60)
rateLimitPerUserPerMinute Int @default(20)
status String @default("active") // active | suspended
rotatedAt DateTime?
revokedAt DateTime?
createdAt DateTime @default(now())
@@map("product_integrations")
}
model CustomerReference {
id String @id @default(cuid())
externalUserId String
externalTenantId String
createdAt DateTime @default(now())
tickets Ticket[]
@@unique([externalUserId, externalTenantId])
@@map("customer_references")
}
model Category {
id String @id @default(uuid())
productId String
@@ -54,20 +98,660 @@ model Category {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
problems Problem[]
tickets Ticket[]
slaPolicies SLAPolicy[]
@@map("categories")
}
model AuditLog {
id String @id @default(uuid())
userId String?
action String
resource String
payload Json?
createdAt DateTime @default(now())
model Problem {
id String @id @default(cuid())
statement String
symptoms String
impact String?
productId String
categoryId String?
severity String
customerImpact String?
businessImpact String?
environment String?
createdAt DateTime @default(now())
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
product Product @relation(fields: [productId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
tickets Ticket[]
investigations Investigation[]
rootCauses RootCause[]
solutions Solution[]
@@map("problems")
}
model Ticket {
id String @id @default(cuid())
code String @unique // <PRODUCT_CODE>-<YEAR>-<SEQUENCE> — see
// specs/003-ticketing/research.md "Ticket code format"
productId String
problemId String
customerId String
externalUserId String // denormalized copy of CustomerReference's field, for query
externalTenantId String // convenience without a join — see data-model.md
status String @default("NEW") // one of the 12 lifecycle states — see
// specs/003-ticketing/research.md "Ticket lifecycle state machine"
priority String
severity String
categoryId String?
idempotencyKey String?
version Int @default(1) // optimistic concurrency — see research.md
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
product Product @relation(fields: [productId], references: [id])
problem Problem @relation(fields: [problemId], references: [id])
customer CustomerReference @relation(fields: [customerId], references: [id])
category Category? @relation(fields: [categoryId], references: [id])
messages TicketMessage[]
attachments TicketAttachment[]
aiSessions AISupportSession[]
assignments Assignment[]
assignmentHistory AssignmentHistory[]
slaRun SLARun?
escalationEvents EscalationEvent[]
resolution Resolution?
@@unique([productId, idempotencyKey])
@@index([productId, status])
@@index([externalTenantId, externalUserId])
@@map("tickets")
}
model TicketMessage {
id String @id @default(cuid())
ticketId String
type String // CUSTOMER_MESSAGE | AI_MESSAGE | AGENT_MESSAGE | INTERNAL_NOTE |
// SYSTEM_EVENT | INVESTIGATION_NOTE | SOLUTION_NOTE
authorRef String // agentId, "ai", "system", or externalUserId — never a local FK
body String
visibleToCustomer Boolean // set from the type->visibility map at write time — see
// specs/003-ticketing/research.md "Message type -> visibility mapping"
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id])
@@index([ticketId, visibleToCustomer, createdAt])
@@map("ticket_messages")
}
model TicketAttachment {
id String @id @default(cuid())
ticketId String
storageKey String // S3/MinIO object key — never the file itself
fileName String
mimeType String
sizeBytes Int
scanStatus String @default("pending") // pending | clean | infected | rejected
uploadedBy String // agentId or externalUserId — same non-FK convention as authorRef
createdAt DateTime @default(now())
ticket Ticket @relation(fields: [ticketId], references: [id])
@@map("ticket_attachments")
}
model KnowledgeEntry {
id String @id @default(cuid())
code String // KB-<PRODUCT>-<SEQ>, e.g. KB-DQ-102 — shared across versions,
// logical identifier is (code, version), NOT code alone — see
// specs/004-product-knowledge/research.md "Versioning mechanism"
version Int @default(1)
isCurrentVersion Boolean @default(true)
productId String
feature String?
type String // known_issue | faq | resolution_procedure | operations
problem String?
symptoms String?
errorCode String?
cause String?
recommendedSolution String?
verificationSteps String?
escalationGuidance String?
status String @default("draft") // draft | published | unpublished
effectiveDate DateTime?
categoryScope String[]
validationStatus String @default("unvalidated") // unvalidated | validated
owner String?
lastReview DateTime?
source String?
createdAt DateTime @default(now())
product Product @relation(fields: [productId], references: [id])
@@unique([code, version])
@@index([productId, isCurrentVersion, status, effectiveDate])
@@map("knowledge_entries")
}
model ErrorCode {
id String @id @default(cuid())
code String // e.g. LAYOUT_PARSE_042
productId String
description String
product Product @relation(fields: [productId], references: [id])
knownIssues KnownIssue[]
lookups ErrorCodeLookup[]
@@unique([productId, code])
@@map("error_codes")
}
// 015-reporting-dashboards research.md §6: a durable, append-only audit row recording that a
// known-error-code lookup happened — 014-full-observability's own equivalent
// (supporthub_known_error_lookups_total) is a process-lifetime Prometheus counter, unusable for
// a historical "top errors" report. productId is denormalized from errorCode.productId so the
// Product dashboard's range query never needs to join back through ErrorCode just to filter.
model ErrorCodeLookup {
id String @id @default(cuid())
errorCodeId String
errorCode ErrorCode @relation(fields: [errorCodeId], references: [id])
productId String
product Product @relation(fields: [productId], references: [id])
createdAt DateTime @default(now())
@@index([productId, createdAt])
@@map("error_code_lookups")
}
model KnownIssue {
id String @id @default(cuid())
productId String
errorCodeId String?
description String
status String @default("open")
product Product @relation(fields: [productId], references: [id])
errorCode ErrorCode? @relation(fields: [errorCodeId], references: [id])
@@map("known_issues")
}
model Runbook {
id String @id @default(cuid())
key String // e.g. PDF_HTML_CONVERSION_FAILURE — shared across versions, logical
// identifier is (key, productId, version), NOT key alone — same convention as KnowledgeEntry
version Int @default(1)
isCurrentVersion Boolean @default(true)
productId String
steps Json // ordered array — order preserved exactly as authored
active Boolean @default(true)
product Product @relation(fields: [productId], references: [id])
@@unique([key, productId, version])
@@index([productId, key, isCurrentVersion, active])
@@map("runbooks")
}
model AuditLog {
id String @id @default(cuid())
actor String // ProductIntegration id, agent id, "system", "ai", etc. — never a local
// User foreign key; see specs/002-saas-integration/research.md "Aligning AuditLog"
actorType String // customer | agent | admin | system | ai
action String
entityType String
entityId String
oldValue Json?
newValue Json?
reason String?
metadata Json?
createdAt DateTime @default(now())
@@map("audit_logs")
}
model AISupportSession {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
status String // analyzing | troubleshooting | verifying | resolved | escalated |
// ended_by_agent — mirrored onto Ticket.status through the existing 003 state machine, see
// specs/005-ai-support/research.md "AISupportSession.status drives Ticket.status"
activeRunbookKey String?
currentStepIndex Int?
clarifyingQuestionsAsked Int @default(0)
toolCallCount Int @default(0)
startedAt DateTime @default(now())
endedAt DateTime?
diagnoses AIDiagnosis[]
interactions AIInteraction[]
actions AIAction[]
knowledgeRefs AIKnowledgeReference[]
@@index([ticketId, status])
@@map("ai_support_sessions")
}
model AIDiagnosis {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
product String
feature String?
problemType String
severity String
confidence Float
possibleCauses String[]
createdAt DateTime @default(now())
@@index([sessionId])
@@map("ai_diagnoses")
}
model AIInteraction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
role String // customer | ai
content String
createdAt DateTime @default(now())
@@index([sessionId, createdAt])
@@map("ai_interactions")
}
model AIAction {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
toolName String
input Json
riskLevel String // low | medium | high — copied from the registry at evaluation time
evaluationOutcome String // approved | pending_approval | refused
refusalReason String?
approvedBy String? // system-policy | agentId | null while pending_approval
createdAt DateTime @default(now())
result AIActionResult?
@@index([sessionId, createdAt])
@@map("ai_actions")
}
model AIActionResult {
id String @id @default(cuid())
actionId String @unique
action AIAction @relation(fields: [actionId], references: [id])
output Json
status String // success | failed
createdAt DateTime @default(now())
@@map("ai_action_results")
}
model AIKnowledgeReference {
id String @id @default(cuid())
sessionId String
session AISupportSession @relation(fields: [sessionId], references: [id])
knowledgeId String // KnowledgeEntry.id — resolved through ai-support/knowledge's public
// index.ts, not a cross-module DB-level FK (Constitution Principle III)
relevanceScore Float?
createdAt DateTime @default(now())
@@index([sessionId])
@@map("ai_knowledge_references")
}
model AIConfidencePolicy {
id String @id @default(cuid())
productId String? // null = system-wide default row
product Product? @relation(fields: [productId], references: [id])
categoryId String? // null = applies to every category of productId
highThreshold Float
lowThreshold Float
maxClarifyingQuestions Int
updatedAt DateTime @updatedAt
@@unique([productId, categoryId])
@@map("ai_confidence_policies")
}
model Team {
id String @id @default(cuid())
name String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
agents Agent[]
hierarchyNodes HierarchyNode[]
@@map("teams")
}
model Agent {
id String @id @default(cuid())
teamId String
team Team @relation(fields: [teamId], references: [id])
name String
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Nullable link to the login identity this routing/skills profile belongs to — schema
// capability only, no workflow sets it yet; see specs/010-identity-auth/research.md.
userId String? @unique
user User? @relation(fields: [userId], references: [id])
skills AgentSkill[]
availability AgentAvailability?
assignments Assignment[]
@@index([teamId, active])
@@map("agents")
}
model AgentSkill {
id String @id @default(cuid())
agentId String
agent Agent @relation(fields: [agentId], references: [id])
skillTag String
level Int // proficiency, used by a future SKILL_BASED assignment strategy — not
// interpreted by this feature
@@unique([agentId, skillTag])
@@map("agent_skills")
}
model AgentAvailability {
id String @id @default(cuid())
agentId String @unique
agent Agent @relation(fields: [agentId], references: [id])
status String // available | busy | away | offline — validated at the schema layer
workingHours Json // per business calendar — opaque to this feature
currentLoad Int @default(0)
updatedAt DateTime @updatedAt
// Last-write-wins on purpose — see specs/006-support-organization/research.md "Availability
// concurrency"; no expectedVersion field here, unlike Ticket.status/KnowledgeEntry.version.
@@map("agent_availability")
}
model HierarchyNode {
id String @id @default(cuid())
name String
parentId String?
parent HierarchyNode? @relation("HierarchyTree", fields: [parentId], references: [id])
children HierarchyNode[] @relation("HierarchyTree")
order Int
teamId String?
team Team? @relation(fields: [teamId], references: [id])
skills String[]
productScope String[] // external product ids; empty = matches every product
categoryScope String[] // free text; empty = matches every category
priorityScope String[] // free text; empty = matches every priority
assignmentStrategy String // free-text reference — no real strategy table exists yet (Phase 7)
slaPolicyId String? // free-text reference — no SlaPolicy table exists yet (Phase 8)
escalationPolicyId String? // free-text reference — no EscalationPolicy table exists yet
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)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
escalationRules EscalationRule[]
@@index([parentId, order])
@@index([active])
@@map("hierarchy_nodes")
}
model Assignment {
id String @id @default(cuid())
ticketId String // not unique — one row per assignment period, see
// specs/007-orchestration-assignment/research.md
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String
agent Agent @relation(fields: [agentId], references: [id])
strategy String // ROUND_ROBIN | LEAST_LOADED | SKILL_BASED | MANUAL | DIRECT
reason String?
isCurrent Boolean @default(true)
assignedAt DateTime @default(now())
unassignedAt DateTime?
@@index([ticketId, isCurrent])
@@index([agentId, isCurrent])
@@map("assignments")
}
model AssignmentHistory {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
agentId String? // null for a "no eligible agent" outcome — FR-008
action String // assigned | reassigned | unassigned
strategy String
reason String?
actor String // system | agentId | adminId
createdAt DateTime @default(now())
@@index([ticketId, createdAt])
@@map("assignment_history")
}
model SLAPolicy {
id String @id @default(cuid())
name String
productId String? // wildcard when null — see data-model.md "Resolution"
product Product? @relation(fields: [productId], references: [id])
categoryId String?
category Category? @relation(fields: [categoryId], references: [id])
problemTypeId String? // free-text — no ProblemType table exists in this codebase
priority String? // free-text, matches Ticket.priority
firstResponseMinutes Int
investigationMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
resolutionMinutes Int
customerResponseMinutes Int? // stored per doc06; not read by this feature (spec.md Assumptions)
businessCalendarId String? // null = 24/7, no exclusions — an explicit policy choice
businessCalendar BusinessCalendar? @relation(fields: [businessCalendarId], references: [id])
active Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
slaRuns SLARun[]
@@index([productId, categoryId, active])
@@map("sla_policies")
}
model SLARun {
id String @id @default(cuid())
ticketId String @unique // one run per ticket — no reopen-cycle support (spec.md Assumptions)
ticket Ticket @relation(fields: [ticketId], references: [id])
policyId String
policy SLAPolicy @relation(fields: [policyId], references: [id])
firstResponseDueAt DateTime?
resolutionDueAt DateTime?
status String // running | paused | warning | breached | completed
pausedAt DateTime?
resumedAt DateTime?
breachedAt DateTime?
// Additive refinement beyond doc06 (research.md/data-model.md): records a first-response
// breach separately from the resolution-timer breach status above, and doubles as the
// idempotency guard for the breach-detection sweep (never re-fires on the same run).
firstResponseBreachedAt DateTime?
completedAt DateTime?
// 016-load-concurrency-testing: optimistic-concurrency counter, identical convention to
// Ticket.version (003-ticketing) — guards pause/resume/complete/the breach sweep against
// racing each other and silently clobbering this run's state (research.md §2).
version Int @default(0)
@@index([status, resolutionDueAt])
@@index([status, firstResponseDueAt])
@@map("sla_runs")
}
model BusinessCalendar {
id String @id @default(cuid())
name String
timezone String // IANA zone name, e.g. "America/New_York"
workingHours Json // { mon?: {start,end}, tue?: ..., ... } — see research.md
holidays Holiday[]
policies SLAPolicy[]
@@map("business_calendars")
}
model Holiday {
id String @id @default(cuid())
calendarId String
calendar BusinessCalendar @relation(fields: [calendarId], references: [id], onDelete: Cascade)
date DateTime // compared by calendar date only, in the calendar's own timezone
description String?
@@index([calendarId, date])
@@map("holidays")
}
model EscalationPolicy {
id String @id @default(cuid())
name String
productId String? // wildcard (global) when null — see research.md "Escalation policy resolution"
product Product? @relation(fields: [productId], references: [id])
active Boolean @default(true)
rules EscalationRule[]
@@index([productId, active])
@@map("escalation_policies")
}
model EscalationRule {
id String @id @default(cuid())
policyId String
policy EscalationPolicy @relation(fields: [policyId], references: [id])
triggerType String // one of doc05 §6's 10 values; only resolution_breach/first_response_breach
// are ever evaluated by this feature — the other 8 are valid, stored, inert config
// (research.md)
condition Json // stored, not evaluated, by this feature (research.md)
targetNodeId String
targetNode HierarchyNode @relation(fields: [targetNodeId], references: [id])
notify Json // who/how to notify — stored and returned only, no delivery mechanism exists
active Boolean @default(true)
@@index([policyId, triggerType, active])
@@map("escalation_rules")
}
model EscalationEvent {
id String @id @default(cuid())
ticketId String
ticket Ticket @relation(fields: [ticketId], references: [id])
ruleId String? // null for a manual escalation or a breach with no matching rule
fromNodeId String?
toNodeId String?
reason String
triggeredBy String // system | <agentId> | <adminId>
createdAt DateTime @default(now())
@@index([ticketId, createdAt])
@@map("escalation_events")
}
model Investigation {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
investigator String
findings Json
evidence Json?
internalNotes String? // never exposed on a customer-facing read — see
// specs/009-problem-resolution/spec.md FR-003
status String @default("open") // open | complete
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("investigations")
}
model RootCause {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
type String // technical | configuration | external_dependency | business |
// contributing_factor
description String
createdAt DateTime @default(now())
@@index([problemId, createdAt])
@@map("root_causes")
}
model Solution {
id String @id @default(cuid())
problemId String
problem Problem @relation(fields: [problemId], references: [id])
proposed String
approved Boolean @default(false)
createdAt DateTime @default(now())
implementation SolutionImplementation?
verification SolutionVerification?
@@index([problemId, createdAt])
@@map("solutions")
}
model SolutionImplementation {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
notes String?
implementedBy String
implementedAt DateTime @default(now())
@@map("solution_implementations")
}
model SolutionVerification {
id String @id @default(cuid())
solutionId String @unique
solution Solution @relation(fields: [solutionId], references: [id])
method String // automated | technical_test | customer_confirmation | agent_confirmation
result String // success | failed
evidence Json?
verifiedAt DateTime @default(now())
@@map("solution_verifications")
}
model Resolution {
id String @id @default(cuid())
ticketId String @unique
ticket Ticket @relation(fields: [ticketId], references: [id])
outcome String
resolvedBy String // "ai" | agentId — see specs/009-problem-resolution/data-model.md
resolvedAt DateTime @default(now())
@@map("resolutions")
}
+2 -2
View File
@@ -2,10 +2,10 @@ import { PrismaClient } from '@prisma/client';
export async function seedCategories(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding baseline product categories...');
console.log(' Seeding baseline product categories...');
const product = await prisma.product.findUnique({
where: { code: 'CORE_PLATFORM' },
where: { externalProductId: 'CORE_PLATFORM' },
});
if (!product) return;
+7
View File
@@ -1,9 +1,15 @@
import { randomUUID } from 'crypto';
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
export async function seedDemoData(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding demo environment data...');
// Legacy demo row, pre-existing since before 010-identity-auth: a CUSTOMER-role User is
// never a real login identity (customer identity is exclusively SaaS-delegated, see
// specs/010-identity-auth/spec.md Assumptions) — passwordHash is populated only to satisfy
// the column's NOT NULL constraint; this account can never authenticate via /auth/login.
await prisma.user.upsert({
where: { email: 'john.doe@example.com' },
update: {},
@@ -11,6 +17,7 @@ export async function seedDemoData(prisma: PrismaClient): Promise<void> {
email: 'john.doe@example.com',
name: 'John Doe (Demo Customer)',
role: UserRole.CUSTOMER,
passwordHash: await bcrypt.hash(randomUUID(), 10),
},
});
}
+5 -5
View File
@@ -1,17 +1,17 @@
import { PrismaClient, ProductStatus } from '@prisma/client';
import { PrismaClient } from '@prisma/client';
export async function seedProducts(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
console.log(' -> Seeding baseline products...');
await prisma.product.upsert({
where: { code: 'CORE_PLATFORM' },
where: { externalProductId: 'CORE_PLATFORM' },
update: {},
create: {
code: 'CORE_PLATFORM',
externalProductId: 'CORE_PLATFORM',
name: 'Core SupportHub Platform',
description: 'Main enterprise ticketing and support engine',
status: ProductStatus.ACTIVE,
supportEnabled: true,
status: 'active',
},
});
}
+8
View File
@@ -1,4 +1,10 @@
import { PrismaClient, UserRole } from '@prisma/client';
import bcrypt from 'bcryptjs';
// Local/development bootstrap credentials only (specs/010-identity-auth/spec.md Edge Cases) —
// never used for a real deployment, which provisions its own first admin out of band.
const DEV_ADMIN_PASSWORD = 'ChangeMe123!';
const DEV_AGENT_PASSWORD = 'ChangeMe123!';
export async function seedRoles(prisma: PrismaClient): Promise<void> {
// eslint-disable-next-line no-console
@@ -11,6 +17,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'admin@supporthub.internal',
name: 'System Admin',
role: UserRole.ADMIN,
passwordHash: await bcrypt.hash(DEV_ADMIN_PASSWORD, 10),
},
});
@@ -21,6 +28,7 @@ export async function seedRoles(prisma: PrismaClient): Promise<void> {
email: 'agent@supporthub.internal',
name: 'Default Support Agent',
role: UserRole.AGENT,
passwordHash: await bcrypt.hash(DEV_AGENT_PASSWORD, 10),
},
});
}
@@ -0,0 +1,57 @@
# Specification Quality Checklist: Continuous Integration Pipeline
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-08-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Deferred, out of scope for this feature: flaky-test retry/quarantine policy (see Edge Cases).
- Tool choice (e.g. which CI system) is deliberately left out of this spec — the constitution's
Technology & Platform Constraints section already commits to Jenkins per docs/09; that mapping
belongs in `/speckit-plan`, not here.
- All items pass; no revision iterations were needed.
## Implementation notes (added during /speckit-implement)
- `docker-compose.test.yml` had fixed `container_name` values (`support-test`,
`postgres-test`, `redis-test`) on all three services — this would have made FR-009/SC-005
(concurrent-run isolation) impossible, since Docker container names must be unique per host
regardless of Compose project. Removed them so Compose auto-names containers per project
(verified locally: two `up` runs under different `-p` project names now produce
`<project>-postgres-1` / `<project>-redis-1` etc. with no collision).
- `docker compose ... down` needs the same `--env-file` flag as `up`, or it can fail to resolve
service config and leave containers running — confirmed by hitting this locally; the
`Jenkinsfile`'s `post { always { ... } }` teardown includes it.
- The existing `test:unit` npm script (`vitest run` with no path filter) currently runs the
entire `tests/**/*.test.ts` glob — including integration/E2E — because `vitest.config.ts`'s
`include` isn't scoped per script; only `test:integration`/`test:e2e` narrow by passing an
explicit directory. Today's "integration" tests are instantiation-only checks (no real DB/Redis
calls yet), so this isn't currently harmful, but it means the `Unit test` stage doesn't
actually isolate unit-only coverage. Out of scope to fix here (not part of this feature's
requirements) — worth a follow-up once real integration tests exist.
@@ -0,0 +1,53 @@
# Contract: Jenkins Pipeline Stage Sequence
The pipeline is the interface between "an engineer proposes a change" and "a validated,
deployable artifact exists." This document is the contract other tooling (and future features)
can rely on.
## Stage order (fixed — matches Constitution → Testing, Observability & CI/CD Gates)
```
Checkout
→ Install
→ Environment validation
→ Typecheck
→ Lint (includes scripts/check-architecture.ts — see research.md)
→ Format check
→ Unit test
→ Integration test (requires postgres/redis via docker-compose.test.yml)
→ E2E test (requires postgres/redis via docker-compose.test.yml)
→ Build
→ Docker build
→ Publish (skipped on validate-only runs — see below)
→ Deploy (skipped on validate-only runs — see below)
```
## Guarantees (callable contract)
1. **Ordering is fixed.** A stage never runs before a stage that precedes it in the list above.
2. **First failure halts.** If any stage from `Checkout` through `Docker build` fails, no
subsequent stage runs; the Pipeline Run's overall status is `fail`, and `Publish`/`Deploy`
never execute against a broken build (FR-003, FR-005).
3. **Validate-only runs stop after `Docker build`.** Any change that isn't targeting a branch with
a configured Deploy Target runs every validation and build stage, but `Publish`/`Deploy` are
skipped, not failed (spec.md Edge Cases).
4. **Environment validation fails fast and specifically.** A missing/malformed required
environment variable is reported by name before any test stage runs (FR-002) — it reuses
`src/config/env.ts`'s existing Zod error, it does not invent a new error format.
5. **Failure output is self-contained.** The reported failure for any stage includes which stage
failed and its output, sufficient for an engineer to diagnose without reproducing locally
(FR-004, SC-002).
6. **No secret ever comes from a repo-committed file.** Every credential used in `Environment
validation`, `Integration test`, `E2E test`, `Publish`, or `Deploy` is injected from the CI
system's credential store at run time (FR-008, SC-004).
7. **Runs are isolated.** Two Pipeline Runs executing concurrently never share a workspace, a
Docker Compose project name, or build artifacts (FR-009, SC-005).
8. **Run history is queryable without server access.** Every past Pipeline Run's overall status
and per-stage results remain visible through the CI system's own UI/API (FR-010).
## Non-goals (explicitly out of contract)
- Flaky-test retry/quarantine behavior (deferred — spec.md Edge Cases).
- Any deploy mechanism beyond `docker compose -f docker-compose.<target>.yml up -d` (no
Kubernetes/Helm contract exists yet).
- The `supporthub-web` frontend pipeline (separate scope).
+48
View File
@@ -0,0 +1,48 @@
# Phase 1 Data Model: Continuous Integration Pipeline
This feature has no application/Prisma data model — it introduces no new database entities. The
"entities" below (from spec.md's Key Entities) are Jenkins-native concepts, recorded here only
so the contract between them is explicit; none require new persistence code.
## Pipeline Run
Represents one execution of the full validate → build → publish → deploy sequence for a single
proposed change.
| Field | Meaning | Source of truth |
|---|---|---|
| id / build number | Unique identifier for the run | Jenkins build number |
| trigger ref | Commit SHA / PR reference that triggered the run | Jenkins SCM checkout metadata |
| stage results | Ordered list of Stage Result (see below) | Jenkins declarative pipeline `stages` block |
| overall status | pass / fail | Jenkins build result |
| deploy target | Which environment (if any) this run published/deployed to | Jenkins pipeline parameter, derived from branch (main → prod pipeline job; other branches → validate-only, no deploy) |
**Lifecycle**: created on trigger → stages execute in order → stops at first failing stage (FR-003)
→ terminal state (pass/fail) is immutable once set.
## Stage Result
The outcome of one stage (checkout, install, env validation, typecheck, lint, format check, unit
test, integration test, E2E test, build, Docker build, publish, deploy) within a Pipeline Run.
| Field | Meaning |
|---|---|
| stage name | One of the fixed stage names in Constitution → Testing, Observability & CI/CD Gates |
| status | pass / fail / skipped (later stages are "skipped" once an earlier stage fails, per FR-003/FR-005) |
| output | Captured log output for that stage, surfaced to the engineer (FR-004) |
| duration | Stage execution time |
**Relationship**: many Stage Results belong to one Pipeline Run, ordered.
## Deploy Target
An environment a validated build can be published/deployed to.
| Field | Meaning |
|---|---|
| name | `test` \| `staging` \| `production` (matches existing `.env.test`/`.env.development`/`.env.prod` + `docker-compose.*.yml` split) |
| credentials | Reference into Jenkins credentials store (never repo-committed — see research.md) |
| compose file | The corresponding `docker-compose.<target>.yml` |
**Relationship**: a Pipeline Run targets at most one Deploy Target for its publish/deploy stages;
validate-only runs (e.g. feature-branch builds) have no Deploy Target (Edge Cases in spec.md).
+113
View File
@@ -0,0 +1,113 @@
# Implementation Plan: Continuous Integration Pipeline
**Branch**: `001-ci-pipeline` | **Date**: 2026-08-21 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/001-ci-pipeline/spec.md`
## Summary
Add an automated CI/CD pipeline (Jenkins, per the constitution's Technology & Platform
Constraints and `docs/09-testing-observability-cicd.md` §3) that runs on every proposed change:
checkout → install → environment validation → typecheck → lint → format check → unit test →
integration test → E2E test → build → Docker build → publish → deploy. It wires together
quality gates and npm scripts that already exist in the repo — it does not introduce new checks,
only automates and sequences the ones already defined in `package.json`.
## Technical Context
**Language/Version**: Groovy (Jenkins declarative pipeline) driving Node.js 20 (per `engines` in
`package.json`) / TypeScript 5.4 build steps.
**Primary Dependencies**: Jenkins (declarative pipeline, `Jenkinsfile` at repo root), Docker /
Docker Compose (already present as `docker-compose.development.yml`, `docker-compose.test.yml`,
`docker-compose.prod.yml`), the existing npm scripts (`typecheck`, `lint`, `format:check`,
`test:unit`, `test:integration`, `test:e2e`, `build`), Prisma CLI (`prisma:generate`,
`prisma:deploy`) for schema/client generation before build.
**Storage**: N/A for the pipeline itself — it depends on ephemeral PostgreSQL/Redis instances
(brought up via `docker-compose.test.yml`) to run integration/E2E tests against.
**Testing**: Vitest (`test:unit`, `test:integration`, `test:e2e`) — already configured; the
pipeline invokes these, it does not define new test tooling.
**Target Platform**: Linux CI agent (Jenkins), producing a Linux container image; deploy targets
are the `development`, `test`, and `prod` environments already defined via
`docker-compose.*.yml` + `.env.*` files.
**Project Type**: Backend service (single Fastify modular monolith) — this feature only adds
CI/CD tooling around the existing `supporthub-api` project; no new application code paths.
**Performance Goals**: N/A (process/tooling feature, not a runtime performance concern). Informal
target: full validate→build pipeline completes in a time that keeps PR feedback fast (not
formally measured by this feature).
**Constraints**: MUST NOT read production secrets from the repository (constitution Principle
governance + FR-008); MUST fail fast on missing/invalid environment configuration before running
expensive test stages; MUST isolate concurrent runs (FR-009) — Jenkins agent workspace-per-build
satisfies this natively.
**Scale/Scope**: One `Jenkinsfile` at repo root for `supporthub-api`. Out of scope: the sibling
`supporthub-web` frontend pipeline (separate repo/feature if/when needed), flaky-test
retry/quarantine policy (explicitly deferred in spec.md Edge Cases).
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
| Principle / Section | Check | Result |
|---|---|---|
| III. Layered Architecture With Enforced Module Boundaries | This feature adds no application code — no controllers/services/repositories touched. | PASS — N/A |
| VI. Durable Audit & History | Not directly applicable to CI itself; pipeline run history is retained by Jenkins (build history), satisfying FR-010's "visible without server/log access" via the Jenkins UI. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Not applicable — no SLA timers or job handlers introduced. | PASS — N/A |
| Technology & Platform Constraints | Constitution names Jenkins explicitly for CI/CD; this plan uses Jenkins declarative pipeline, not an alternative CI tool. | PASS |
| Testing, Observability & CI/CD Gates | Constitution requires exactly this stage order: checkout → install → env validation → typecheck → lint → format check → unit → integration → E2E → build → Docker build → publish → deploy. Plan matches verbatim. | PASS |
| Governance ("secrets never committed") | Plan requires Jenkins-managed credentials store for all env/prod secrets. Verified in Phase 0 (research.md): `.env.*` files were found committed with real dev credentials, fixed out-of-band (commit `2093898` — untracked, `.env.example` added); pipeline generates `.env.<target>` from Jenkins credentials at runtime, never reads the repo copy. | PASS (post-design) |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design (data-model.md, contracts/, quickstart.md). No
new violations introduced — this feature adds zero application code, only pipeline
configuration, so Principles I, II, IV, V, VIII (identity boundary, config-over-hardcode, AI
policy, evidence-based verification, ticket/problem separation) are not applicable and were
correctly excluded from the gate table above.
## Project Structure
### Documentation (this feature)
```text
specs/001-ci-pipeline/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output (minimal — see note)
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output (pipeline stage contract)
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── Jenkinsfile # NEW — declarative pipeline, stages per Constitution
├── package.json # EXISTING — source of truth for script names each stage calls
├── docker-compose.development.yml # EXISTING — used for local/dev parity, not directly by CI
├── docker-compose.test.yml # EXISTING — brings up ephemeral Postgres/Redis for CI test stages
├── docker-compose.prod.yml # EXISTING — referenced by the deploy stage for prod rollout
├── .env.development / .env.test / .env.prod # EXISTING — env files; secrets injected by Jenkins
│ credentials at pipeline runtime, not read from repo
└── scripts/
└── check-architecture.ts # EXISTING — architecture boundary check; candidate addition to
the lint/typecheck stage (confirmed in research.md)
```
**Structure Decision**: Single project (this is the existing `supporthub-api` backend). No new
application source directories are introduced — the only new artifact is a root-level
`Jenkinsfile` plus its supporting CI documentation under `specs/001-ci-pipeline/`. The sibling
`supporthub-web` repository is explicitly out of scope (see Technical Context → Scale/Scope).
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+56
View File
@@ -0,0 +1,56 @@
# Quickstart: Validating the CI Pipeline
Prerequisites: a Jenkins instance with the `Jenkinsfile` (to be added at repo root by the
implementation) registered as a Multibranch Pipeline job pointed at this repository, with
credentials configured for `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `JWT_SECRET`, AWS keys, and a
container registry, per `research.md`'s secrets-handling decision.
## Scenario 1 — a bad change is caught and blocked (User Story 1)
1. On a feature branch, introduce a deliberate failure, e.g. add a lint violation to any file
under `src/`.
2. Push the branch / open a PR.
3. **Expected**: the pipeline job triggers automatically (FR-001), runs
`Checkout → Install → Environment validation → Typecheck` (pass) `→ Lint` (fail), then stops —
no `Unit test`/`Build`/`Publish`/`Deploy` stage runs (FR-003, FR-005).
4. **Expected**: the Jenkins build result shows the `Lint` stage as failed, with the ESLint output
visible directly in the stage log — no local reproduction needed to see why it failed
(FR-004, SC-002).
5. Revert the violation, push again. **Expected**: all stages through `Docker build` pass.
## Scenario 2 — environment misconfiguration fails fast (Edge Case)
1. Temporarily remove/rename a required variable from the credentials injected for a test run
(e.g. `DATABASE_URL`).
2. Trigger a run.
3. **Expected**: the `Environment validation` stage fails immediately, before `Typecheck`/`Unit
test`, with the same descriptive error `src/config/env.ts`'s Zod schema already produces
(FR-002).
## Scenario 3 — a validated change deploys without manual steps (User Story 2)
1. Merge a clean change into the branch mapped to the `test` Deploy Target.
2. **Expected**: the pipeline runs every validation stage, then `Build → Docker build → Publish →
Deploy`, and the `test` environment is running the new image afterward — with no engineer
running a deploy command by hand (FR-007, SC-003).
3. Inspect the deploy stage's credential usage: confirm no step reads `.env.test`/`.env.prod`
from the repository checkout — only from the CI system's injected credentials (FR-008, SC-004).
## Scenario 4 — a validate-only run never deploys (Edge Case)
1. Push a commit to a branch with no configured Deploy Target (e.g. a random feature branch).
2. **Expected**: all stages through `Docker build` run and pass; `Publish`/`Deploy` are skipped,
not attempted and not marked as failed.
## Scenario 5 — concurrent runs don't interfere (Edge Case)
1. Trigger two pipeline runs at the same time (e.g. push to two different branches, or re-run the
same job twice back to back).
2. **Expected**: each run gets its own workspace and Compose project name; one run's test database
state or build artifact never appears in or affects the other (FR-009, SC-005).
## What "done" looks like
All five scenarios above pass, and `specs/001-ci-pipeline/checklists/requirements.md` plus this
quickstart together demonstrate every functional requirement and success criterion in `spec.md`
without needing to read the `Jenkinsfile` itself to know what "correct" means.
+102
View File
@@ -0,0 +1,102 @@
# Phase 0 Research: Continuous Integration Pipeline
No `NEEDS CLARIFICATION` markers remained in the Technical Context after `/speckit-plan`'s
Technical Context pass — this document records the decisions behind that context rather than
resolving open unknowns.
## Decision: CI system — Jenkins declarative pipeline
- **Decision**: Use a single `Jenkinsfile` (declarative syntax) at the repo root.
- **Rationale**: The constitution's Technology & Platform Constraints section and
`docs/09-testing-observability-cicd.md` §3 both name Jenkins explicitly, with a defined stage
order. This isn't a free choice — using anything else would need a constitution amendment.
- **Alternatives considered**: GitHub Actions / GitLab CI — rejected only because the governing
docs already commit to Jenkins; otherwise equally viable for this repo's needs.
## Decision: Environment/secrets handling in the pipeline
- **Decision**: The pipeline injects `POSTGRES_PASSWORD`, `REDIS_PASSWORD`, `JWT_SECRET`, and AWS
credentials from Jenkins' credentials store as environment variables / a generated `.env.*`
file written into the workspace at runtime — never read from a file committed to the
repository.
- **Rationale**: `.env.development`, `.env.test`, and `.env.prod` were found committed to git
with real dev credentials in plain text (fixed separately: untracked, `.env.example` added,
see repo commit `2093898`). `docker-compose.test.yml`'s `app` service still declares
`env_file: .env.test`, so the pipeline's test stage must materialize a `.env.test` in the
workspace from Jenkins credentials immediately before `docker compose up`, then discard it when
the stage ends — the checked-in `.env.test` template must only ever contain non-secret
placeholder values from here on, matching `.env.prod`'s existing `CHANGE_ME` pattern.
Production deploy correspondingly generates `.env.prod` the same way, from Jenkins prod
credentials, never from the repo copy.
- **Alternatives considered**: Docker secrets / mounted files instead of generated `.env` files —
viable but a larger change to `docker-compose.*.yml`; deferred as out of scope since it doesn't
change the pipeline's external behavior (FR-008 is satisfied either way).
## Decision: Environment validation stage
- **Decision**: The environment-validation stage runs the existing `env.ts` Zod schema
(`src/config/env.ts`) against the materialized environment before any test stage starts, by
invoking a lightweight script (e.g. `node --env-file=.env.<target> -e "require('./dist/src/config/env.js')"`
post-build, or a dedicated `tsx` invocation pre-build) so a missing/malformed variable fails
immediately with the schema's existing descriptive Zod error, satisfying FR-002.
- **Rationale**: `src/config/env.ts` already throws a specific, actionable error
(`❌ Invalid environment variables: ...`) on `safeParse` failure — no new validation logic is
needed, just an early pipeline invocation of the existing one.
- **Alternatives considered**: A separate shell script re-implementing required-var checks —
rejected as duplicate logic that could drift from the real Zod schema.
## Decision: Quality stage contents
- **Decision**: Stage-to-script mapping is direct:
- `install``npm ci`
- `typecheck``npm run typecheck`
- `lint``npm run lint` (consider folding `scripts/check-architecture.ts`'s module-boundary
check into this stage, since it enforces constitution Principle III and already runs in
`.husky/pre-commit` — confirmed as in-scope, see Assumptions below)
- `format check``npm run format:check`
- `unit test``npm run test:unit`
- `integration test``npm run test:integration` (requires `docker-compose.test.yml`'s
`postgres`/`redis` services running first)
- `e2e test``npm run test:e2e` (same dependency)
- `build``npm run build:prod` (or `build:test`/`build:development` depending on target,
matching the `BUILD_COMMAND` pattern already used by each `docker-compose.*.yml`)
- `docker build``docker build` using the existing root `Dockerfile`
- **Rationale**: Every stage maps to a script that already exists and is already exercised
locally/in the pre-commit hook — the pipeline's job is orchestration and environment isolation,
not defining new checks (matches plan.md's Summary).
- **Alternatives considered**: None — this mapping is essentially forced by "don't introduce new
checks" (spec.md Assumptions).
## Decision: Publish/deploy mechanism
- **Decision**: `publish` pushes the built image to a container registry (registry choice left to
implementation/tasks phase — no registry is currently configured in the repo); `deploy` runs
`docker compose --env-file <generated .env> -f docker-compose.<target>.yml up -d` on the target
host/agent, reusing the `docker:up:*` npm scripts' underlying compose invocation.
- **Rationale**: The repo already models per-environment deployment as
`docker compose -f docker-compose.<env>.yml up -d` (see `docker:up:dev`, `docker:up:test`,
`docker:up:prod` in `package.json`) — the pipeline should drive the same mechanism an engineer
would run by hand today, not invent a new one.
- **Alternatives considered**: Kubernetes/Helm deploy — no k8s manifests exist in the repo today;
out of scope unless a future feature introduces them.
## Decision: Concurrent-run isolation (FR-009)
- **Decision**: Rely on Jenkins' per-build workspace isolation (each pipeline run gets its own
workspace directory and, for the Docker-dependent stages, project-scoped Compose project names
e.g. `-p support-test-${BUILD_NUMBER}`) rather than building custom isolation logic.
- **Rationale**: This is a built-in Jenkins guarantee once each build uses its own workspace and
Compose project name; no additional application code is needed.
- **Alternatives considered**: None needed — default Jenkins behavior already satisfies this when
Compose project names are parameterized by build number.
## Assumptions carried over from spec.md, confirmed against the codebase
- `package.json` scripts (`typecheck`, `lint`, `format:check`, `test:unit`, `test:integration`,
`test:e2e`, `build*`, `docker:*`) are confirmed present and are the source of truth for stage
behavior.
- `docker-compose.development.yml` / `.test.yml` / `.prod.yml` are confirmed present and already
encode per-environment deploy shape.
- `.husky/pre-commit` already runs `lint-staged` and `scripts/check-architecture.ts` locally —
the CI lint stage should run the same architecture check server-side so a bypassed/missing
local hook can't let a boundary violation merge.
+145
View File
@@ -0,0 +1,145 @@
# Feature Specification: Continuous Integration Pipeline
**Feature Branch**: `001-ci-pipeline`
**Created**: 2026-08-21
**Status**: Draft
**Input**: User description: "Close out Phase 1 (engineering foundation) gaps: an automated CI pipeline that validates every change before it can be merged/deployed, per docs/09-testing-observability-cicd.md section 3."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Every change is automatically validated before merge (Priority: P1)
An engineer pushes a change (new commit or pull request) to the repository. Before the change
can be merged or deployed, the system automatically checks out the code, installs dependencies,
validates required environment/configuration, and runs type-checking, linting, format-checking,
and the automated test suites (unit, integration, E2E) against it, then reports pass/fail back
to the engineer.
**Why this priority**: This is the baseline safety net every other phase depends on. Without it,
regressions in later phases (ticketing, orchestration, AI support) can reach production
undetected, and the constitution's "MUST verify compliance before merge" governance rule has no
automated enforcement.
**Independent Test**: Push a commit that fails a lint rule (or a failing test) and confirm the
pipeline reports failure and blocks the change; push a clean commit and confirm the pipeline
reports success end-to-end.
**Acceptance Scenarios**:
1. **Given** a new commit is pushed, **When** the pipeline runs, **Then** it executes checkout,
dependency install, environment validation, type-check, lint, format-check, unit tests,
integration tests, and E2E tests, in that order, and stops at the first failing stage.
2. **Given** all validation stages pass, **When** the pipeline reaches the build stage, **Then**
it produces a build artifact and a container image ready for the next stage.
3. **Given** any validation stage fails, **When** the pipeline reports status, **Then** the
engineer can see which stage failed and why, without needing to reproduce the failure
manually to get that information.
---
### User Story 2 - A validated build can be published and deployed without manual steps (Priority: P2)
Once a change has passed all validation stages, the system publishes the resulting build
artifact/image and can deploy it to an environment (e.g. test/staging/production) using
environment-specific configuration and credentials, without an engineer manually running deploy
commands.
**Why this priority**: Automating publish/deploy is what makes the validation in User Story 1
actually load-bearing — a validated build that still requires manual, error-prone deploy steps
undermines the safety the pipeline is meant to provide. It's second priority because User Story
1 (catching regressions) delivers value even before deploy is automated.
**Independent Test**: Merge a validated change and confirm it is published and deployed to a
target environment automatically, with no manual command execution required.
**Acceptance Scenarios**:
1. **Given** a build has passed every validation stage, **When** the pipeline reaches
publish/deploy, **Then** the artifact is published and deployed to the target environment
using that environment's own configuration and credentials.
2. **Given** a deploy targets a production environment, **When** the pipeline runs, **Then** it
uses protected, environment-managed credentials and never reads secrets from a file committed
to the repository.
---
### Edge Cases
- What happens when a required environment variable/secret is missing for the target
environment? The pipeline MUST fail fast at the environment-validation stage with a clear
message identifying what's missing, before running any test or build stage.
- What happens when a pipeline run is triggered for a branch/change that has no deploy target
(e.g., a feature branch, not main)? The pipeline MUST still run all validation stages through
build, but MUST NOT publish or deploy.
- How does the system handle two changes validating concurrently? Each run MUST be isolated —
one run's failure or artifacts must not affect a concurrent run for a different change.
- What happens when a stage (e.g. E2E tests) is flaky and fails intermittently for reasons
unrelated to the change? Out of scope for this feature — flaky-test quarantine/retry policy is
a separate concern to be addressed if/when it becomes a problem.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST automatically run on every proposed change (commit/pull request)
without requiring an engineer to manually trigger validation.
- **FR-002**: The system MUST validate that required environment configuration is present and
well-formed before running any test stage, and MUST fail with a specific, actionable message
if it is not.
- **FR-003**: The system MUST run, in order, and stop at the first failure: type-checking,
lint checks, format checks, unit tests, integration tests, and end-to-end tests.
- **FR-004**: The system MUST report which stage failed and the relevant failure output back to
the engineer who proposed the change, without requiring local reproduction to see it.
- **FR-005**: The system MUST only proceed to build/publish/deploy stages after every prior
validation stage has passed.
- **FR-006**: The system MUST produce a versioned, reproducible build artifact and container
image once validation passes.
- **FR-007**: The system MUST support deploying the same validated artifact to multiple
environments (at minimum: test/staging and production), using environment-specific
configuration.
- **FR-008**: The system MUST NOT read production secrets/credentials from any file committed
to the repository — environment credentials MUST be supplied by the pipeline's own protected
configuration at run time.
- **FR-009**: The system MUST isolate concurrent pipeline runs so that one change's validation
or build artifacts cannot affect another concurrent run.
- **FR-010**: The system MUST make current and historical pipeline run status (pass/fail, per
stage) visible to engineers without requiring direct server/log access.
### Key Entities
- **Pipeline Run**: One execution of the full validate → build → publish → deploy sequence for
a specific change; has an ordered list of stage results and an overall pass/fail outcome.
- **Stage Result**: The outcome (pass/fail, output) of one stage (e.g. lint, unit test) within a
Pipeline Run.
- **Deploy Target**: An environment (test, staging, production) a validated build can be
published/deployed to, with its own configuration and credentials.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of proposed changes are validated automatically before merge — zero changes
reach the main branch without having passed the pipeline.
- **SC-002**: An engineer can determine which validation stage failed, and why, within 1 minute
of the pipeline completing, without reproducing the issue locally.
- **SC-003**: A validated change can be deployed to any supported environment with zero manual
deploy commands run by an engineer.
- **SC-004**: No production secret ever appears in repository history (verified by secret-scan
of the repository).
- **SC-005**: Two changes validating at the same time never interfere with each other's result
(zero cross-run contamination incidents).
## Assumptions
- "Environments" for deploy purposes are, at minimum, test/staging and production, matching the
`.env.test` / `.env.development` / `.env.prod` split already present in the codebase's package
scripts.
- The existing local quality scripts (typecheck, lint, format:check, test:unit, test:integration,
test:e2e, build) are the source of truth for what each pipeline stage runs — this feature wires
them into an automated, triggered pipeline rather than defining new checks.
- Deployment targets are container-based (the repository already has Docker Compose files per
environment), so "publish" means publishing a container image and "deploy" means rolling it
out via the existing container orchestration for that environment.
+203
View File
@@ -0,0 +1,203 @@
---
description: "Task list for 001-ci-pipeline"
---
# Tasks: Continuous Integration Pipeline
**Input**: Design documents from `specs/001-ci-pipeline/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md), [contracts/pipeline-stage-contract.md](./contracts/pipeline-stage-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Not requested in spec.md as automated test tasks — this feature's own "tests" are the
5 quickstart scenarios, run manually against a real Jenkins instance and included as verification
tasks within each story below.
**Organization**: Tasks are grouped by user story (US1 = P1, US2 = P2) to enable independent
implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files/independent stage blocks, no dependency on an
incomplete task)
- **[Story]**: Which user story this task belongs to (US1, US2)
- All file paths are relative to `supporthub-api/` (repo root)
## Path Conventions
Single project — this feature adds one new root-level file, `Jenkinsfile`, plus edits to
`.gitignore`/docs. No `src/` changes (this feature adds no application code, per plan.md).
---
## Phase 1: Setup
**Purpose**: Get a buildable pipeline skeleton and confirm the container build this pipeline will
drive actually works today, before wiring stage logic into it.
- [X] T001 Create `Jenkinsfile` at repo root with declarative pipeline skeleton: `agent`,
`options { disableConcurrentMultipleBuilds... }`, empty `stages {}` block, and a `post`
block placeholder — in `Jenkinsfile`
- [X] T002 [P] Verify the existing multi-stage `Dockerfile` builds cleanly outside CI
(`docker build --build-arg BUILD_COMMAND="npm run build:prod" -t supporthub-api-ci .`) so
the pipeline's `Docker build` stage has a known-good target — no file changes, verification
only
**Checkpoint**: A no-op pipeline exists and the Docker build it will call is confirmed working.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: The stages every user story's stages sit on top of — checkout, dependency install,
env validation, and Prisma client generation, all required regardless of which story's stages
run next.
**⚠️ CRITICAL**: No user-story stage work can be added until this phase is complete.
- [X] T003 Add `Checkout` stage (SCM checkout) to `Jenkinsfile`
- [X] T004 Add `Install` stage (`npm ci`) to `Jenkinsfile` (depends on T003)
- [X] T005 Add `Environment validation` stage to `Jenkinsfile`: materialize `.env.<target>` from
Jenkins credentials (per research.md's secrets decision — never read the repo's `.env.*`),
then invoke the existing Zod schema in `src/config/env.ts` so a missing/malformed variable
fails immediately with its existing descriptive error (FR-002) (depends on T004)
- [X] T006 [P] Add a `Generate Prisma client` step (`npm run prisma:generate`) to `Jenkinsfile`,
required before `Typecheck`/`Build` can succeed (depends on T004)
**Checkpoint**: Checkout → install → env validation → Prisma generate all run and pass on a clean
commit. User Story 1's stages can now be added.
---
## Phase 3: User Story 1 - Every change is automatically validated before merge (Priority: P1) 🎯 MVP
**Goal**: A proposed change is automatically checked out, installed, environment-validated, and
run through typecheck/lint/format/unit/integration/E2E/build/docker-build, stopping at the first
failure and reporting which stage failed with its output.
**Independent Test**: Push a commit with a deliberate lint violation and confirm the pipeline
fails at `Lint` and never reaches later stages (Quickstart Scenario 1); push a commit with a
missing required env var and confirm `Environment validation` fails first (Quickstart Scenario 2).
### Implementation for User Story 1
- [X] T007 [US1] Add `Typecheck` stage (`npm run typecheck`) to `Jenkinsfile` (depends on T006)
- [X] T008 [US1] Add `Lint` stage to `Jenkinsfile`, running both `npm run lint` and
`npx tsx scripts/check-architecture.ts` (module-boundary check, matches `.husky/pre-commit`
and enforces Constitution Principle III server-side) (depends on T007)
- [X] T009 [US1] Add `Format check` stage (`npm run format:check`) to `Jenkinsfile` (depends on T008)
- [X] T010 [US1] Add `Unit test` stage (`npm run test:unit`) to `Jenkinsfile` (depends on T009)
- [X] T011 [US1] Add a step before `Integration test` that brings up ephemeral `postgres`/`redis`
via `docker-compose.test.yml`, with the Compose project name parameterized by
`${BUILD_NUMBER}` for run isolation (FR-009), in `Jenkinsfile` (depends on T010)
- [X] T012 [US1] Add `Integration test` stage (`npm run test:integration`) to `Jenkinsfile`
(depends on T011)
- [X] T013 [US1] Add `E2E test` stage (`npm run test:e2e`) to `Jenkinsfile` (depends on T011)
- [X] T014 [US1] Add `Build` stage (`npm run build:prod`, or the target-specific `build:*` script
matching the resolved Deploy Target) to `Jenkinsfile` (depends on T012, T013)
- [X] T015 [US1] Add `Docker build` stage using the root `Dockerfile` (T002's verified command) to
`Jenkinsfile` (depends on T014)
- [X] T016 [US1] Add a `post` block to `Jenkinsfile` that surfaces which stage failed and its
captured output on failure (FR-004, SC-002), and tears down the ephemeral
`docker-compose.test.yml` stack (`always`) regardless of outcome
- [ ] T017 [US1] Manually run Quickstart Scenarios 1, 2, and 5 from
`specs/001-ci-pipeline/quickstart.md` against a real Jenkins job and confirm all three pass
**Checkpoint**: User Story 1 is fully functional — every proposed change is validated end-to-end
through `Docker build`, and failures are diagnosable from the Jenkins UI alone. This is a
deployable/demoable increment even before US2 exists (Publish/Deploy just wouldn't run yet).
---
## Phase 4: User Story 2 - A validated build can be published and deployed without manual steps (Priority: P2)
**Goal**: A build that has passed every Phase 3 stage is published (image pushed to a registry)
and deployed to its target environment automatically, using environment-specific credentials —
with `Publish`/`Deploy` skipped (not failed) on changes that have no configured Deploy Target.
**Independent Test**: Merge a clean change into the branch mapped to the `test` Deploy Target and
confirm the `test` environment is running the new image afterward with zero manual deploy
commands (Quickstart Scenario 3); push to a branch with no Deploy Target and confirm
`Publish`/`Deploy` are skipped, not attempted (Quickstart Scenario 4).
### Implementation for User Story 2
- [X] T018 [US2] Add branch → Deploy Target resolution logic to `Jenkinsfile` (e.g. `main` → prod,
a designated test branch → test; everything else → no Deploy Target) (depends on T015)
- [X] T019 [US2] Add `Publish` stage to `Jenkinsfile`: push the `Docker build` image to a
container registry, guarded to run only when a Deploy Target was resolved (T018) (depends
on T018)
- [X] T020 [US2] Add a step to `Jenkinsfile` that generates the target's `.env.<target>` from
Jenkins credentials immediately before deploy (never from the repo copy, per research.md),
scoped to the `Deploy` stage's workspace only (depends on T018)
- [X] T021 [US2] Add `Deploy` stage to `Jenkinsfile`: run
`docker compose --env-file <generated> -f docker-compose.<target>.yml up -d` against the
resolved Deploy Target, guarded the same way as `Publish` (depends on T019, T020)
- [X] T022 [US2] Confirm (via `Jenkinsfile` `when` conditions) that `Publish`/`Deploy` are marked
`skipped`, not `failed`, on runs with no resolved Deploy Target (depends on T018)
- [ ] T023 [US2] Manually run Quickstart Scenarios 3 and 4 from
`specs/001-ci-pipeline/quickstart.md` against a real Jenkins job and confirm both pass,
including verifying no step reads `.env.test`/`.env.prod` from the repository checkout
**Checkpoint**: Both user stories work independently and together — a validated change now
reaches its target environment with no manual deploy step, and unvalidated/no-target changes stop
cleanly after `Docker build`.
---
## Phase 5: Polish & Cross-Cutting Concerns
**Purpose**: Documentation and final verification once both stories are implemented.
- [X] T024 [P] Add a short "CI/CD" section to `README.md` describing how the pipeline is
triggered, where to view run status, and how to configure required Jenkins credentials
(cross-reference `specs/001-ci-pipeline/quickstart.md`)
- [X] T025 [P] Review `Jenkinsfile` line-by-line to confirm no credential value or literal
environment secret was hardcoded anywhere in the file (SC-004) — should only ever reference
Jenkins credential IDs, never raw values
- [X] T026 Update `specs/001-ci-pipeline/checklists/requirements.md` Notes if implementation
surfaced any spec gap not previously captured
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — start immediately
- **Foundational (Phase 2)**: Depends on Setup (T001) — BLOCKS both user stories
- **User Story 1 (Phase 3)**: Depends on Foundational completion — no dependency on US2
- **User Story 2 (Phase 4)**: Depends on User Story 1's `Docker build` stage existing (T015) —
unlike a typical spec-kit feature, US2 is not independently implementable before US1 here,
because "publish/deploy a validated build" has nothing to publish/deploy until US1's build
stages exist. US2 remains independently *testable* (Quickstart Scenarios 3-4 are separate from
1-2-5) even though it isn't independently *implementable* first.
- **Polish (Phase 5)**: Depends on both user stories being complete
### Parallel Opportunities
- T002 (Dockerfile verification) can run in parallel with T001 (Jenkinsfile skeleton creation)
- T006 (Prisma generate step) can run in parallel with T005 (env validation step) once T004 is done
- T024 and T025 in Polish can run in parallel
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Complete Phase 1: Setup (T001-T002)
2. Complete Phase 2: Foundational (T003-T006)
3. Complete Phase 3: User Story 1 (T007-T017)
4. **STOP and VALIDATE**: Run Quickstart Scenarios 1, 2, 5 against a real Jenkins job
5. This alone satisfies SC-001, SC-002, and half of SC-005 — a real, demoable safety net — before
any deploy automation exists
### Incremental Delivery
1. Setup + Foundational → pipeline skeleton runs and validates environment
2. Add User Story 1 → validate/build automatically on every change (MVP)
3. Add User Story 2 → validated builds deploy automatically, still skipping cleanly when there's
no target
4. Polish → documentation and a final secrets/hardcoding review
@@ -0,0 +1,100 @@
# Specification Quality Checklist: SaaS Product Integration & Inbound Request Trust
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-08-21
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Auth mechanism choice (signed tokens/OAuth2/mTLS) and rotation-window length are deliberately
left to `/speckit-plan`, not decided here — see spec.md Assumptions.
- Idempotency-key enforcement is explicitly deferred to the future ticketing feature (FR-012
reserves the field only); this is a scope boundary, not a gap.
- Exact rate-limit values and auth-mechanism-per-integration defaults are
`REQUIRES BUSINESS CONFIRMATION` per docs/10-implementation-roadmap.md — not invented here.
- All items pass; no revision iterations were needed.
## Implementation notes (added during /speckit-implement)
- **Found and fixed a codebase-wide bug, not specific to this feature**: `src/app.ts` called
`app.setErrorHandler(...)` *after* `bootstrapRoutes(app)` had already registered every domain
module's routes. Fastify resolves each encapsulated child context's error handler at the time
that context is registered — a handler set on the parent afterwards does not retroactively
apply to already-registered children. Every module registered via `app.register(someRoutes)`
(which is every module in this codebase, since none use `fastify-plugin`) was silently falling
back to Fastify's default `{statusCode, error, message}` error shape instead of this app's
`{success:false, error:{code,message,details}, requestId}` envelope, for *any* error — not
just ones from this feature's plugin. Fixed by moving `setErrorHandler`/`setNotFoundHandler`
before `bootstrapRoutes` in `src/app.ts`. Covered by a new regression test in
`tests/unit/app.test.ts` (verified it fails without the fix, passes with it).
- **Found and fixed a second bug in the same handler**: the generic (non-`AppError`,
non-`ZodError`) fallback branch always returned `500`, even for framework-level errors that
already carry their own client-facing `statusCode` (e.g. Fastify's body-parser rejecting
malformed JSON is a `400`, not a server failure). Fixed to preserve the original
`statusCode`/`code` when it's in the 4xx range.
- **Found and fixed a pre-existing DB/Redis wiring gap that only became harmful because of this
feature**: `vitest.config.ts`'s hardcoded test `DATABASE_URL` (`localhost:5432`) and default
Redis config don't correspond to any service `docker-compose.test.yml` actually publishes to
the host, so `test:integration` could never reach a real database under this repo's own
tooling. This was harmless while every "integration" test was an instantiation-only check (see
`specs/001-ci-pipeline/checklists/requirements.md`), but this feature's integration test
(`tests/integration/product-integration-auth.test.ts`) makes real Prisma/Redis calls. Rather
than leave a newly-introduced test permanently broken for anyone without a coincidentally
matching local Postgres, fixed `test:unit`'s script to scope to `tests/unit` only (it was
running the entire `tests/**` glob, including integration/E2E, via no path argument) — matching
`test:integration`/`test:e2e`'s existing explicit scoping. `test:integration` itself still needs
a reachable Postgres/Redis (via `docker-compose.test.yml` in CI, or a local equivalent) and was
manually verified end-to-end against a temporary Docker Postgres/Redis (see PR description) —
it is not run as part of `npm test`.
- Manually verified all of spec.md's User Story 1 acceptance scenarios end-to-end against a live
server + Postgres + Redis (via temporary Docker containers), beyond what the automated tests
cover: valid in-scope acceptance, indistinguishable invalid-credential/unregistered-product
rejection, unknown-field rejection, out-of-scope rejection, and replay rejection.
- **User Story 2 (admin onboarding/rotation/revocation/audit-trail) is now implemented and
automatically tested** (`tests/integration/product-integrations-admin.test.ts`, run against a
real Postgres/Redis, verified passing). **Known limitation carried over from the existing
codebase, not introduced by this feature**: the admin routes are gated by
`fastify.authenticate` (`src/plugins/auth.plugin.ts`), which is currently a no-op stub — it
never actually verifies a JWT or rejects an unauthenticated caller. These admin endpoints are
therefore not really access-controlled yet. Fixing this requires the `identity/auth` module
(itself unimplemented) and is out of scope for this feature — flagged here and in
`contracts/inbound-request-contract.md` so it isn't mistaken for "done."
- **User Story 3 (rate limiting) is now implemented and automatically tested**
(`tests/integration/inbound-rate-limit.test.ts`, run against a real Postgres/Redis, verified
passing): a bespoke Redis fixed-window counter (`checkRateLimit`,
`src/infrastructure/cache/rate-limiter.ts`) rather than `@fastify/rate-limit`'s default
`onRequest`-stage hook — that hook runs before this feature's preHandler-based auth resolves
the integration/user identity the limit needs to key on, so a second preHandler
(`checkIntegrationRateLimit`) runs after `authenticateProductIntegration` and checks the
integration-level limit, then the per-user limit, independently. Verified both are enforced
independently (a single user's own throttling doesn't affect others; the integration cap
throttles even when no individual user has hit their own limit).
- All three user stories (P1, P2, P3) of this feature are now implemented and covered by
integration tests verified against a live Postgres/Redis, in addition to the unit tests for the
crypto/token primitives. `docs/06-database-schema.md` itself is intentionally not modified —
it's the source spec this implementation follows, not generated output.
@@ -0,0 +1,80 @@
# Contract: Inbound SaaS Request Authentication
## Request
Every inbound request from an integrated SaaS product carries:
- **Header**: `Authorization: Bearer <signed-token>` — the signed short-lived token from
research.md ("Signed short-lived token format").
- **Body**: JSON matching the Inbound Request Contract shape in `data-model.md`, validated with a
`.strict()` Zod schema.
## Validation order (fixed — each step's failure short-circuits the rest)
Request body shape is checked first because it's cheap and stateless — no reason to spend a
crypto verification or a database lookup on a request that's malformed anyway:
1. **Request body matches the strict schema** (no unknown fields) → else `400 VALIDATION_ERROR`.
2. **`Authorization: Bearer <token>` header present and well-formed** → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
3. **`ProductIntegration` exists for the body's `productId`** → else
`401 INVALID_INTEGRATION_CREDENTIAL` (identical to step 4's failure — see FR-010).
4. **Token verifies** against that integration's `credentialRef` or non-expired
`previousCredentialRef` → else `401 INVALID_INTEGRATION_CREDENTIAL`.
5. **Token not expired** (beyond the configured clock-skew tolerance) → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
6. **Token `jti` not previously seen** (replay check against Redis) → else
`401 INVALID_INTEGRATION_CREDENTIAL`.
7. **`ProductIntegration.revokedAt IS NULL`** → else `401 INVALID_INTEGRATION_CREDENTIAL`.
8. **`ProductIntegration.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
9. **`Product.status == 'active'`** → else `403 PRODUCT_INTEGRATION_SUSPENDED`.
10. **`tenantId`/`userId` fall within `ProductIntegration.allowedScope`** → else
`403 REQUEST_OUT_OF_SCOPE`.
11. **Rate limit (integration-level, then user-level) not exceeded** → else `429 RATE_LIMIT_EXCEEDED`
(User Story 3 — applied after auth succeeds, on the resolved integration/user identity).
Only after all eleven checks pass does `request.reqContext` get populated
(`productId` → internal `Product.id`, `customerId``CustomerReference.id`,
`tenantId``externalTenantId`, `actorType``CUSTOMER`, `actorId``externalUserId`) and the
request reaches its route handler. Every attempt — pass or fail at any step — writes one
`AuditLog` row (data-model.md).
## Guarantees (callable contract)
1. **No side effect before full validation.** No `CustomerReference` row, no `AuditLog` success
row, no downstream processing happens until step 9 passes.
2. **Identical response for "unregistered" and "invalid credential."** Per research.md's FR-010
decision — callers cannot distinguish "you don't exist" from "you exist but this credential is
wrong."
3. **Distinguishable suspension and scope errors.** `403 PRODUCT_INTEGRATION_SUSPENDED` and
`403 REQUEST_OUT_OF_SCOPE` are each their own error code, safe to distinguish per research.md.
4. **No raw credential value ever appears in a log, audit row, or error response.**
5. **A revoked credential is rejected starting with the very next request** — no propagation
delay (SC-002).
6. **During a rotation's transition window, both the old and new credential validate
successfully** (SC-003).
7. **An unknown field anywhere in the request body rejects the entire request**, not just that
field (FR-008).
## Admin: Integration Lifecycle Endpoints
Extends the existing `catalog/products` module (`src/modules/catalog/products/`). Registration is
keyed by the product's *external* id (the product may not exist locally yet — registering an
integration creates it); every other operation is keyed by the `ProductIntegration`'s own id,
since that's what registration returns and what admin tooling references thereafter:
| Route | Operation | Effect |
|---|---|---|
| `POST /admin/products/:externalProductId/integration` | Register integration | Finds-or-creates the `Product`, then creates its `ProductIntegration` with a freshly generated `credentialRef`/secret, `authMechanism: 'signed_token'`, and the request body's `allowedScope` |
| `POST /admin/integrations/:integrationId/rotate` | Rotate credential | Moves current `credentialRef``previousCredentialRef`, sets `previousCredentialExpiresAt` (research.md's rotation transition window), issues a new `credentialRef`/secret; returns the new secret exactly once (never retrievable again — matches "never persist raw credential," see research.md "Credential storage") |
| `POST /admin/integrations/:integrationId/revoke` | Revoke credential | Sets `revokedAt`; both current and previous credentials become invalid immediately |
| `PATCH /admin/integrations/:integrationId/status` | Update status | Sets `ProductIntegration.status` (`active`/`suspended`) — independent of `Product.status` |
| `GET /admin/integrations/:integrationId/audit-trail` | Get audit trail | Lists `AuditLog` rows where `entityType = 'ProductIntegration'` and `entityId` matches, newest first |
All five require an admin-authenticated caller via the existing human/admin JWT plugin
(`fastify.authenticate`, `src/plugins/auth.plugin.ts`) — a separate concern from the
product-integration signed-token auth this contract otherwise describes. **Known limitation**:
`auth.plugin.ts`'s `authenticate` decorator is currently a stub that performs no real JWT
verification (it exists as scaffolding — see `src/modules/identity/auth`, itself unimplemented).
These admin endpoints are therefore not actually access-controlled yet; real JWT verification is
a separate, pre-existing gap this feature surfaces but does not fix.
+89
View File
@@ -0,0 +1,89 @@
# Phase 1 Data Model: SaaS Product Integration & Inbound Request Trust
All models below use `cuid()` ids, matching `docs/06-database-schema.md`. This supersedes the
current placeholder `Product` model in `prisma/schema.prisma` (see research.md "Reconciling the
placeholder Prisma schema").
## Product (revised)
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| externalProductId | String @unique | Reference into the SaaS — not authoritative here (Constitution Principle I) |
| name | String | |
| supportEnabled | Boolean @default(true) | Per spec.md/docs/01 §5: support is enabled by default per product |
| status | String | `active` \| `suspended` \| `deprecated` — admin-editable, drives FR-011/FR-012 |
| createdAt / updatedAt | DateTime | |
**Relations added by this feature**: `integration ProductIntegration?` (1:1). Relations to
`KnowledgeEntry[]`, `Runbook[]`, `Ticket[]` from doc 06 are deferred until those models exist in
their owning features (Phase 3/5) — Prisma can't reference a model that doesn't exist yet.
## ProductIntegration
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| productId | String @unique | 1:1 with Product |
| credentialRef | String | AES-256-GCM-encrypted secret (ciphertext, not plaintext) — see research.md "Credential storage" for why this isn't a real secret-manager pointer yet |
| previousCredentialRef | String? | Same encryption, set during a rotation's transition window (research.md) |
| previousCredentialExpiresAt | DateTime? | When the previous credential stops being accepted |
| authMechanism | String | `signed_token` for this feature; free-text so a future integration can use `oauth2_client_credentials` or `mtls` without a schema change |
| allowedScope | Json | Structured scope: at minimum `{ tenantIds?: string[], allowAnyTenant?: boolean }` — validated against inbound `tenantId`/`userId` (FR-003) |
| rateLimitPerMinute | Int @default(60) | Integration-level limit (FR-009), admin-editable |
| rateLimitPerUserPerMinute | Int @default(20) | Per-user-within-integration limit (FR-009) |
| status | String @default("active") | `active` \| `suspended` — independent of Product.status so an integration can be disabled without touching the product record |
| rotatedAt | DateTime? | Last rotation timestamp |
| revokedAt | DateTime? | Set on revocation; a revoked integration's `credentialRef` and `previousCredentialRef` are both immediately invalid regardless of `previousCredentialExpiresAt` |
| createdAt | DateTime @default(now()) | |
**Validation rule**: A token is accepted only if it verifies against `credentialRef`, OR against
`previousCredentialRef` AND `now() < previousCredentialExpiresAt` — AND `revokedAt IS NULL` — AND
the owning `Product.status == 'active'` AND this record's own `status == 'active'`.
## CustomerReference
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| externalUserId | String | Reference only — never authoritative (Constitution Principle I) |
| externalTenantId | String | |
| createdAt | DateTime @default(now()) | |
**Unique constraint**: `@@unique([externalUserId, externalTenantId])` — first-seen wins; repeat
requests for the same user+tenant reuse the same reference row rather than creating duplicates.
## Authentication Audit Event → reuses `AuditLog`
No new model. Every authentication attempt writes one `AuditLog` row:
| AuditLog field | Value for an auth event |
|---|---|
| actor | The `ProductIntegration.id` if resolvable (even on failure, once the credential at least identifies *a* product), else `"unknown"` |
| actorType | `system` |
| action | `integration.auth.success` \| `integration.auth.failure` |
| entityType | `ProductIntegration` |
| entityId | The `ProductIntegration.id` |
| reason | On failure: which check failed (`invalid_credential` \| `suspended` \| `out_of_scope` \| `expired` \| `replayed`) — never the raw token/credential |
| metadata | `{ externalUserId?, externalTenantId? }` — no raw credential value, ever (FR-007/FR-010) |
| createdAt | now() |
## Inbound Request Contract (validated shape, not persisted as its own table)
Extends `docs/02-integration-and-security.md` §3 with the reserved idempotency field from
spec.md FR-012:
| Field | Type | Notes |
|---|---|---|
| productId | string | The *external* product id as the caller knows it — resolved to internal `Product.id` during validation |
| tenantId | string | → `CustomerReference.externalTenantId` |
| userId | string | → `CustomerReference.externalUserId` |
| source | string | e.g. `"docuqube-web"` |
| problem | string | Free-text — not validated/interpreted by this feature |
| feature | string? | Optional |
| referenceIds | string[]? | Optional |
| context | Record<string, unknown>? | Optional |
| idempotencyKey | string? | Reserved per FR-012 — accepted and echoed if present, not deduplicated against anything yet |
Validated with a Zod `.strict()` schema (research.md) — any field not in this list rejects the
whole request.
+136
View File
@@ -0,0 +1,136 @@
# Implementation Plan: SaaS Product Integration & Inbound Request Trust
**Branch**: `002-saas-integration` | **Date**: 2026-08-21 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/002-saas-integration/spec.md`
## Summary
Add the trust boundary between an integrating SaaS product and SupportHub: a `ProductIntegration`
record per product (credential reference, allowed scope, rotation/revocation state), a signed
short-lived-token service-to-service auth mechanism validated on every inbound request via a new
Fastify plugin, per-integration/per-user rate limiting, and admin CRUD for onboarding/rotating/
revoking an integration. Populates the existing `RequestContext` (`productId`/`customerId`/
`tenantId`) so every later module can trust that context without re-validating it.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+, matching the rest of the repo.
**Primary Dependencies**: Fastify (new plugin), `@fastify/rate-limit` (already a dependency,
currently registered with a single global limit — extended with a per-route `keyGenerator`),
Zod (inbound contract schema), Prisma (new models), Node's built-in `crypto` (HMAC signing/
verification for the signed-token mechanism — no new signing library needed).
**Storage**: PostgreSQL via Prisma — adds `ProductIntegration` and `CustomerReference` models,
and aligns the existing placeholder `Product` model with `docs/06-database-schema.md`'s real
shape (see research.md "Reconciling the placeholder Prisma schema").
**Testing**: Vitest — unit tests for token verification/scope-checking logic, integration tests
for the full inbound-request preHandler against a real Postgres (per existing
`docker-compose.test.yml`, wired up by the 001-ci-pipeline feature).
**Target Platform**: Same Fastify modular monolith; this feature adds one new Fastify plugin and
one module's worth of admin endpoints — no new service, no new deployable unit.
**Project Type**: Backend service — single project, no frontend changes in this feature (an admin
UI for onboarding/rotating integrations is Phase 10 per the roadmap; this feature only needs the
API surface admin tooling will eventually call).
**Performance Goals**: Credential/token validation must not add meaningfully to request latency —
target under 10ms added overhead per request for the signed-token verification path (in-process
HMAC check, no external call).
**Constraints**: MUST NOT log or persist raw credential/token values (FR-007, FR-010 from
spec.md); MUST reject unknown fields on the inbound contract (FR-008); rate limiting MUST be
adjustable without a deploy (Constitution Principle II).
**Scale/Scope**: One inbound endpoint contract (the `ProductToSupportHubRequest` shape from
doc 02 §3, extended with the reserved `idempotencyKey` field from spec.md FR-012), plus admin
endpoints for integration lifecycle (register, rotate, revoke, list, get). Does not include
ticket creation itself — this feature validates and trusts the request; acting on it (creating a
ticket) is the ticketing feature.
## 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 | `CustomerReference` stores only `externalUserId`/`externalTenantId` as references, never a copy of SaaS user/tenant data; SupportHub never authenticates the end customer itself, only the product's service-to-service credential. | PASS |
| II. Configuration Over Hardcoding | Rate limits, integration status, and credential scope are all admin-editable data (`ProductIntegration.allowedScope`, plus a new rate-limit config), not hardcoded. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | New Fastify plugin is infrastructure (like `auth.plugin.ts`), not a module; it only reads validated data via the repository layer of the integration-management module — no controller touches Prisma directly. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI involved 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 | Every auth attempt (success/failure) is written to the existing `AuditLog` model (FR-007) — reused, not duplicated. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Credential rotation must not race with an in-flight validation using the old credential — handled by checking both old/new credential validity within the transition window rather than an atomic cutover (see research.md). No `setTimeout`-based expiry. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Not applicable — this feature predates both entities. | PASS — N/A |
| Technology & Platform Constraints | Uses Fastify/Zod/Prisma/Node crypto only — no new runtime dependency added. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design (data-model.md, contracts/, quickstart.md).
One design detail worth calling out explicitly: `ProductIntegration.authMechanism` is stored as
free text, not an enum restricted to `signed_token` — this is deliberate so a future integration
requiring OAuth2 or mTLS (both still valid per docs/02 §4) doesn't require a schema migration,
keeping this decision genuinely configuration-driven (Principle II) rather than a hardcoded
assumption that every integration uses the same mechanism forever.
## Project Structure
### Documentation (this feature)
```text
specs/002-saas-integration/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output (inbound request contract + admin endpoints)
└── tasks.md # Phase 2 output (/speckit-tasks — not created here)
```
### Source Code (repository root)
```text
supporthub-api/
├── prisma/
│ └── schema.prisma # MODIFIED — align Product with docs/06, add
│ ProductIntegration, CustomerReference
├── src/
│ ├── plugins/
│ │ ├── product-integration-auth.plugin.ts # NEW — validates inbound signed tokens,
│ │ │ scope, product status; populates reqContext
│ │ └── rate-limit.plugin.ts # MODIFIED — per-route keyGenerator support
│ ├── common/
│ │ └── types/
│ │ └── request-context.types.ts # UNCHANGED — productId/customerId/tenantId
│ │ already present, this feature just populates them
│ └── modules/
│ └── catalog/
│ └── products/ # EXTENDED (existing scaffold) — adds
│ ├── controller/ integration lifecycle endpoints alongside
│ ├── service/ existing product endpoints, since
│ ├── repository/ ProductIntegration is 1:1 with Product
│ ├── schema/ per docs/06
│ ├── mapper/
│ └── types/
└── tests/
├── unit/ # token verification, scope-check logic
└── integration/ # full preHandler against real Postgres
```
**Structure Decision**: Single project, extending the existing `catalog/products` module rather
than introducing a new top-level module — `docs/07-backend-architecture.md`'s module list has no
separate "product-integrations" module, and `docs/06-database-schema.md` nests
`ProductIntegration` directly under Product's own domain grouping (1:1 relation). The inbound
auth *validation* itself is cross-cutting request-handling infrastructure, so it lives in
`src/plugins/`, matching the existing `auth.plugin.ts` pattern for human/admin JWT auth — these
are two distinct auth concerns (product-to-SupportHub vs. person-to-SupportHub) and stay in
separate plugins rather than merged into one.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+73
View File
@@ -0,0 +1,73 @@
# Quickstart: Validating SaaS Product Integration & Inbound Request Trust
Prerequisites: local dev environment running (`docker:up:dev` or equivalent), Prisma migrated
with this feature's schema changes applied, one `Product` + `ProductIntegration` seeded (or
created via the admin endpoints below).
## Scenario 1 — a valid, in-scope request is accepted (User Story 1)
1. Register a product integration (admin endpoint) and note the returned signing secret.
2. Sign a token for that integration with `tenantId`/`userId` values inside its `allowedScope`.
3. Send the inbound request with `Authorization: Bearer <token>` and a body matching the contract.
4. **Expected**: `200`-level response; a `CustomerReference` row exists for the `tenantId`/
`userId`; an `AuditLog` row records `integration.auth.success`.
## Scenario 2 — invalid/unregistered credential is rejected (User Story 1)
1. Send the same request with a token signed by an arbitrary/wrong secret.
2. **Expected**: `401 INVALID_INTEGRATION_CREDENTIAL`; no `CustomerReference` created; an
`AuditLog` row records `integration.auth.failure` with `reason: invalid_credential`.
3. Repeat with a `productId` that has never been registered at all.
4. **Expected**: the exact same `401 INVALID_INTEGRATION_CREDENTIAL` response — confirm the two
failure modes are indistinguishable from the response alone (FR-010).
## Scenario 3 — suspended product is distinguishably rejected (User Story 1, Edge Cases)
1. Set the seeded `ProductIntegration.status` (or `Product.status`) to suspended.
2. Send a request with an otherwise-valid token.
3. **Expected**: `403 PRODUCT_INTEGRATION_SUSPENDED` — distinguishable from Scenario 2's `401`.
## Scenario 4 — unknown field rejects the whole request (User Story 1)
1. Send an otherwise-valid request body with one extra, undefined field.
2. **Expected**: `400 VALIDATION_ERROR` — the request is rejected outright, not partially
processed with the extra field ignored.
## Scenario 5 — credential rotation is zero-downtime (User Story 2)
1. Rotate the seeded integration's credential (admin endpoint) — note both old and new secrets.
2. Immediately send one request signed with the OLD secret and one with the NEW secret.
3. **Expected**: both succeed (SC-003).
4. Wait past the transition window (or adjust it down for the test), then retry with the OLD
secret.
5. **Expected**: `401 INVALID_INTEGRATION_CREDENTIAL` — old credential now rejected.
## Scenario 6 — revocation takes effect immediately (User Story 2)
1. Revoke the seeded integration's credential (admin endpoint).
2. Immediately send a request signed with that credential.
3. **Expected**: `401 INVALID_INTEGRATION_CREDENTIAL` on the very next request (SC-002); an
`AuditLog` row records the revocation itself as an admin action.
## Scenario 7 — audit trail is retrievable (User Story 2)
1. After Scenarios 1-6 above, call the admin "get audit trail" endpoint for the seeded
integration.
2. **Expected**: a chronological list including the success from Scenario 1 and the failures from
Scenarios 2-4, each without any raw credential value present anywhere in the response.
## Scenario 8 — rate limiting throttles one integration/user without affecting others (User Story 3)
1. Seed two separate product integrations, A and B.
2. Send requests from integration A past its configured `rateLimitPerMinute`.
3. **Expected**: later requests from A in the burst receive `429 RATE_LIMIT_EXCEEDED`; concurrent
requests from integration B continue succeeding normally.
4. Within integration A, send requests as two different `userId`s, one past
`rateLimitPerUserPerMinute` and one under it.
5. **Expected**: the over-limit user is throttled; the other user's requests continue succeeding.
## What "done" looks like
All eight scenarios pass, and together they demonstrate every functional requirement and success
criterion in `spec.md` without needing to read the plugin/module implementation to know what
"correct" means.
+178
View File
@@ -0,0 +1,178 @@
# Phase 0 Research: SaaS Product Integration & Inbound Request Trust
## Decision: Signed short-lived token format
- **Decision**: HMAC-SHA256-signed token, structured as a compact JWT
(`header.payload.signature`), signed with the `ProductIntegration`'s own per-product secret
(never a shared/global secret). Payload carries `productId` (SupportHub's internal id, not the
raw external one), `tenantId`, `userId`, `iat`, `exp` (short — target 60s, generous enough for
clock skew tolerance below), and a `jti` (nonce) for replay detection.
- **Rationale**: User picked "signed short-lived tokens" over OAuth2/mTLS for this plan (lowest
operational overhead, no token-issuance service to build, straightforward per-product secret
rotation). JWT is chosen over a bespoke signed-string format purely because Node's ecosystem
already has well-reviewed JWT libraries and it's a format integrating teams will recognize —
but the *validation* logic is fully custom (see below), it doesn't defer trust decisions to a
JWT library's defaults.
- **Alternatives considered**: OAuth2 client-credentials (rejected per user decision — adds a
token-issuance/introspection surface this plan doesn't need); mTLS (rejected — heavier
operationally, reserved as a future per-integration option since `ProductIntegration
.authMechanism` is already a free-text field in docs/06, not an enum, so nothing here blocks
adding mTLS support to a specific high-trust integration later without a schema change).
## Decision: Replay resistance
- **Decision**: Reject a token whose `jti` has been seen before within its own validity window.
Track seen `jti`s in Redis (already an infrastructure dependency — `infrastructure/cache`) with
a TTL matching the token's `exp`, so the tracking set never grows unbounded.
- **Rationale**: A 60-second token expiry alone bounds the replay window but doesn't close it — a
captured token is still valid for up to 60s. `jti`-tracking closes it to "exactly once."
- **Alternatives considered**: Expiry-only (no `jti` tracking) — rejected, doesn't satisfy
spec.md's Edge Cases requirement that a replayed token "MUST be rejected," only that it
eventually stops being accepted.
## Decision: Clock skew tolerance
- **Decision**: Accept a token up to 5 seconds past its `exp` and up to 5 seconds before its `iat`
(both configurable, not hardcoded — Constitution Principle II).
- **Rationale**: Small enough that it doesn't meaningfully widen the replay window beyond what
`jti` tracking already closes, generous enough to absorb realistic NTP drift between two
independently-operated systems.
- **Alternatives considered**: Zero tolerance — rejected as operationally fragile; large tolerance
(e.g. 60s) — rejected as unnecessarily widening the token's effective lifetime.
## Decision: Credential rotation mechanism
- **Decision**: `ProductIntegration` gains a nullable `previousCredentialRef` and
`previousCredentialExpiresAt` alongside the existing `credentialRef`. On rotation: the current
`credentialRef` moves to `previousCredentialRef` with `previousCredentialExpiresAt` set to
"now + transition window," and a new `credentialRef` is issued. Token validation tries the
current secret first, then the previous one (if `previousCredentialExpiresAt` hasn't passed).
- **Rationale**: This is what makes rotation zero-downtime (SC-003) without a distributed
"atomic cutover" — both secrets are simultaneously valid for a bounded window, which directly
satisfies Constitution Principle VII's concurrency requirement without inventing new
coordination infrastructure.
- **Alternatives considered**: Versioned credential list (unbounded history) — rejected as more
than the spec requires (only *one* prior credential needs to remain valid, per spec.md User
Story 2's "old and new credential both valid during a transition window").
## Decision: Credential storage (no secret manager exists yet in this repo)
- **Decision**: `docs/06-database-schema.md` describes `credentialRef` as "a pointer into secret
manager, never the raw secret" — but no secret-manager integration exists anywhere in this
codebase or `docs/07-backend-architecture.md`'s stack today, and HMAC signature verification
needs the actual secret value at verify time, not just a hash of it (unlike a password, which
only ever needs comparison). Pragmatic resolution for this feature: `credentialRef` /
`previousCredentialRef` store the secret **encrypted at rest** with AES-256-GCM, using a new
required env var `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY` (32-byte key, added to
`src/config/env.ts`'s Zod schema). The plaintext secret is generated once at registration/
rotation time, returned to the admin caller exactly once (never retrievable again — matches
spec.md's credential lifecycle), and only its ciphertext is persisted. Decryption happens
in-process, only inside the token-verification path.
- **Rationale**: This satisfies the spirit of "never the raw secret in the database" (plaintext
is never at rest) without inventing a dependency on an external secret-manager service this
repo doesn't have. It's flagged here explicitly as a placeholder: if/when a real secret manager
(Vault, AWS Secrets Manager, etc.) is adopted, `credentialRef` becomes a genuine external
reference and this encryption layer is removed — that migration is out of scope for this
feature and should be called out as a follow-up, not silently implied as "done."
- **Alternatives considered**: Storing the secret in plaintext — rejected outright, directly
contradicts docs/06 and the constitution's secrets-handling governance. Requiring an actual
secret-manager integration before this feature can ship — rejected as disproportionate scope
for what Phase 2 needs; no other part of the roadmap currently requires one either.
## Decision: Rate limiting design (per-integration and per-user)
- **Decision**: Apply `@fastify/rate-limit` at the route level (not the single global registration
currently in `rate-limit.plugin.ts`) for the inbound SaaS-facing route, with a custom
`keyGenerator` returning `` `integration:${productIntegrationId}` `` for the integration-level
limit and a second, stricter per-route rate-limit instance keyed by
`` `integration:${productIntegrationId}:user:${externalUserId}` `` for the per-user limit. Both
read their max/window from `ProductIntegration`-linked configuration (new fields, see
data-model.md), not a hardcoded value.
- **Rationale**: The existing global `rate-limit.plugin.ts` registration (`max: 1000, timeWindow:
'1 minute'`, ungated) stays as a blanket floor for the whole API; it doesn't get removed, just
supplemented — this feature's per-integration/per-user limits are strictly tighter and specific
to the inbound SaaS route. `keyGenerator` needs the validated `productIntegrationId`/
`externalUserId`, so the rate-limit check runs in the route's handler chain *after* the
`product-integration-auth` plugin's preHandler populates `request.reqContext`.
- **Alternatives considered**: A single global per-IP limit — rejected, doesn't satisfy FR-009's
"per integration and independently per end user" requirement; a product's shared outbound IP
would incorrectly throttle every user behind it together.
## Decision: Unknown-field rejection (FR-008)
- **Decision**: The inbound Zod schema uses `.strict()` (rejects any key not explicitly defined),
not the Zod default of silently stripping unknown keys.
- **Rationale**: FR-008 requires the *entire request* rejected on an unknown field, not a
best-effort parse — `.strict()` is exactly this behavior in Zod; the default `.parse()`
behavior (strip unknown keys silently) would violate FR-008.
- **Alternatives considered**: None — this is a direct, unambiguous mapping from requirement to
Zod API.
## Decision: Error response shape without leaking registration status (FR-010)
- **Decision**: "Unregistered product" and "invalid credential for a registered product" return
the *same* generic `401 INVALID_INTEGRATION_CREDENTIAL` response body and status code. "Product
suspended" returns a distinguishable `403 PRODUCT_INTEGRATION_SUSPENDED` (this one is safe to
distinguish — a suspended product's own registered caller already knows it's registered).
"Out-of-scope request" (valid credential, but tenant/user outside `allowedScope`) returns
`403 REQUEST_OUT_OF_SCOPE`.
- **Rationale**: This satisfies both FR-010 requirements at once: distinguishable where doing so
is safe (suspended vs. scope), identical where distinguishing would leak whether an arbitrary
product ID is registered at all (invalid credential vs. unregistered product).
- **Alternatives considered**: Fully distinguishing all four cases — rejected, directly
contradicts FR-010's "without leaking whether an unregistered product ID exists."
## Decision: Reconciling the placeholder Prisma schema
- **Decision**: The current `Product` model in `prisma/schema.prisma` (`code`, `name`,
`description`, `status: ProductStatus` enum) is starter-template scaffolding, not the real
domain model — it doesn't match `docs/06-database-schema.md`'s `Product` shape at all
(`externalProductId`, `supportEnabled`, `status: String`). This feature replaces it with the
doc 06 shape. New models (`ProductIntegration`, `CustomerReference`) use `cuid()` ids matching
doc 06 exactly. The existing `Category`, `User`, and `AuditLog` placeholder models are left
alone (out of scope for this feature — `Category`'s real shape belongs to whichever future
feature builds the catalog domain properly; `User`/`AuditLog` aren't touched by this feature's
requirements beyond *reusing* `AuditLog` for FR-007).
- **Rationale**: Phase 2 is explicitly where `docs/10-implementation-roadmap.md` places
"Product/ProductIntegration models" — this is the correct feature to fix `Product`, not a
scope-creep addition. Leaving `Category`/`User` alone keeps the change bounded to what this
feature actually needs.
- **Alternatives considered**: Adding `ProductIntegration` pointing at the old placeholder
`Product` shape and deferring the `Product` fix — rejected, would mean building
`ProductIntegration.product` against a model with no `externalProductId` to validate inbound
requests against, defeating the feature's own purpose.
## Decision: Aligning `AuditLog` to doc 06's shape (discovered during implementation)
- **Decision**: The placeholder `AuditLog` model (`userId`, `action`, `resource`, `payload`) is
replaced with `docs/06-database-schema.md`'s real shape (`actor`, `actorType`, `action`,
`entityType`, `entityId`, `oldValue`, `newValue`, `reason`, `metadata`, `createdAt`) — dropping
its foreign key to `User`. `actor` becomes a plain string identifier (a `ProductIntegration.id`,
an agent id, `"system"`, etc.), not a relation.
- **Rationale**: Originally planned to leave `AuditLog` untouched and just "reuse" it (see the
"Where `ProductIntegration` lifecycle endpoints live" decision below and plan.md's Constitution
Check), but the placeholder shape has no `entityType`/`entityId`/`reason` fields at all — FR-007
("record which integration/credential was involved," "reason: invalid_credential | suspended |
...") literally cannot be satisfied by the old shape. This isn't scope creep into unrelated
future work — it's a direct, minimal prerequisite for this feature's own FR-007, discovered
while wiring up data-model.md's `AuditLog` field mapping against the real schema. The dropped
`User` relation also better matches Constitution Principle I: an audit `actor` shouldn't require
a local `User` row to exist, since most actors (product integrations, external users via
`externalUserId`, "ai") never have one.
- **Alternatives considered**: Keep the placeholder shape and encode `entityType`/`entityId`/
`reason` inside the existing free-text `resource` field and `payload` JSON — rejected as exactly
the kind of unstructured workaround Constitution Principle VI's audit requirement exists to
prevent; it would make audit rows unqueryable by entity without parsing `payload` first.
## Decision: Where `ProductIntegration` lifecycle endpoints live
- **Decision**: Extend the existing `src/modules/catalog/products/` module (already scaffolded
with controller/service/repository/routes/schema/mapper/types) with integration lifecycle
operations, rather than creating a new top-level module.
- **Rationale**: `docs/07-backend-architecture.md`'s module list has no separate
"product-integrations" module; `docs/06-database-schema.md` groups `ProductIntegration` under
the same "Domain: Integration / Catalog" heading as `Product`, and it's a 1:1 relation.
- **Alternatives considered**: A new `src/modules/platform/integrations/` addition — rejected;
that module already exists for outbound webhook delivery (docs/11 gap A2, a different, future
concern), and conflating inbound-trust management with outbound-webhook delivery in one module
would blur a module boundary the constitution requires to stay clear (Principle III).
+218
View File
@@ -0,0 +1,218 @@
# Feature Specification: SaaS Product Integration & Inbound Request Trust
**Feature Branch**: `002-saas-integration`
**Created**: 2026-08-21
**Status**: Draft
**Input**: User description: "Phase 2 of docs/10-implementation-roadmap.md: SaaS integration —
Product/ProductIntegration models, credential validation, service-to-service auth (signed
tokens/OAuth2/mTLS), inbound request contract, rate limiting. Per docs/02-integration-and-security.md."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - Every inbound request is authenticated and trusted before anything happens (Priority: P1)
A registered SaaS product's backend calls SupportHub on a customer's behalf (e.g., a customer
clicked "Help" inside the product). SupportHub validates the calling product's identity, the
credential presented, the product's current status, and the accompanying user/tenant context
against that product's registered integration and its allowed scope — before any downstream
processing occurs. A request from an unregistered product, an invalid/expired/revoked
credential, a suspended product, or context outside the credential's scope is rejected outright.
**Why this priority**: This is the trust boundary everything else in the system depends on.
Without it, SupportHub cannot safely accept "this is customer X of tenant Y using product Z" as
true, which every later phase (ticketing, AI, orchestration) relies on completely.
**Independent Test**: Send a request with a valid, correctly-scoped credential and confirm it is
accepted and its product/tenant/user context is trusted; send the same request with an invalid,
expired, or wrong-product credential and confirm it is rejected with no side effects.
**Acceptance Scenarios**:
1. **Given** a product has a registered, active integration with a valid credential, **When** it
sends a request with that credential and in-scope context, **Then** the request is accepted
and the product/tenant/user identity it carries is treated as trusted.
2. **Given** a request presents a credential that doesn't match any registered integration,
**When** SupportHub validates it, **Then** the request is rejected and no ticket, session, or
other record is created.
3. **Given** a product's integration status is "suspended," **When** a request arrives for that
product, **Then** it is rejected with a reason distinguishable from "invalid credential" (so
the calling product can tell the difference between "you're not registered" and "you're
registered but temporarily disabled").
4. **Given** a request includes fields not defined in the inbound contract, **When** it is
validated, **Then** the entire request is rejected rather than the unknown fields being
silently ignored.
5. **Given** a request's tenant/user context doesn't fall within the presented credential's
allowed scope, **When** it is validated, **Then** the request is rejected even though the
credential itself is valid.
---
### User Story 2 - An admin can onboard, rotate, and revoke a product's integration credential (Priority: P2)
An operator/admin registers a new SaaS product as a SupportHub integration client, issuing it a
credential scoped to that product alone. Later, the admin can rotate that credential (issue a new
one while the old one keeps working for a defined transition window) or revoke it immediately
(e.g., on suspected compromise), without any SupportHub downtime or a deploy.
**Why this priority**: Without this, User Story 1 has nothing to validate against, and there's no
way to safely respond to a leaked credential — but it's second because a single seeded
integration is enough to prove Story 1 works end to end before onboarding/rotation tooling exists.
**Independent Test**: Register a new product integration and confirm a request using its
credential is accepted (Story 1); rotate the credential and confirm both old and new credentials
work during the transition, then only the new one after; revoke a credential and confirm the very
next request using it is rejected.
**Acceptance Scenarios**:
1. **Given** an admin registers a new product integration, **When** they issue its credential,
**Then** that credential is scoped to that product alone — it is never valid for any other
product's requests.
2. **Given** an active integration, **When** an admin rotates its credential, **Then** requests
using either the old or new credential succeed until the transition window ends, after which
only the new one works.
3. **Given** an active integration, **When** an admin revokes its credential, **Then** the next
request using that credential is rejected, and the revocation is recorded in the audit trail.
4. **Given** any authentication attempt (success or failure) against any integration, **When** it
occurs, **Then** it is recorded in an audit trail an admin can review — including which
integration was involved and the outcome.
---
### User Story 3 - No single product integration or end user can overwhelm the system (Priority: P3)
Inbound requests are rate-limited both per product integration and per end user within that
integration, using limits an admin can change without a deploy. A product (or a single customer
within it) sending requests far beyond its configured limit is throttled; other integrations and
users are unaffected.
**Why this priority**: Important for production resilience and fairness across multiple
integrated products, but the system is meaningfully useful (and Stories 1-2 fully testable)
without it — this hardens an already-working trust boundary rather than enabling new behavior.
**Independent Test**: Send requests from one integration far beyond its configured rate limit and
confirm later requests in the burst are throttled while a concurrent, well-behaved second
integration's requests continue to succeed normally.
**Acceptance Scenarios**:
1. **Given** an integration has a configured rate limit, **When** it is exceeded within the
configured window, **Then** further requests from that integration are throttled until the
window resets.
2. **Given** two different end users under the same integration, **When** one exceeds their
per-user limit, **Then** the other user's requests continue to succeed normally.
3. **Given** an admin changes a rate limit value, **When** the change is saved, **Then** it takes
effect without requiring a deploy or restart.
---
### Edge Cases
- What happens when a credential is presented after its rotation transition window has fully
elapsed? It MUST be treated identically to an already-revoked credential (rejected).
- What happens when the inbound request's signature/token appears valid but is a replay of a
previously-used one (e.g., a captured and resent signed token)? It MUST be rejected — accepted
service-to-service auth mechanisms must be replay-resistant (short-lived tokens with a
nonce/timestamp check, or equivalent).
- What happens when a product has no integration configured at all (never registered)? Requests
MUST be rejected the same way as an invalid credential, without leaking whether the product ID
itself is known to SupportHub.
- What happens when clock skew between the calling product and SupportHub affects a
time-bound signed token's validity window? A small, explicitly bounded tolerance is allowed;
anything beyond it is rejected.
- What happens when an integration is rotated or revoked while a request is mid-flight? The
in-flight request's outcome is decided by validation at the moment it's checked — no partial
application, no race that lets a revoked credential's request complete after revocation is
recorded.
- What happens when the same underlying customer problem is submitted twice in quick succession
(e.g., the calling product's own client retried after a timeout)? Out of scope for this
feature — de-duplicating retried problem reports into a single ticket depends on the `Ticket`
entity, which doesn't exist until the ticketing feature. This feature's contract MUST still
reserve a field for an idempotency key so that later feature can use it without a contract
change (see Assumptions).
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST require every SaaS product that wants support to be registered as
a distinct integration, each with its own credential — never shared across products.
- **FR-002**: The system MUST validate, on every inbound request before any downstream
processing: the calling product's identity, the presented credential, the product's current
status (active/suspended/deprecated), the accompanying user/tenant context, and the
credential's allowed scope.
- **FR-003**: The system MUST reject a request whose product, tenant, or user values are not
corroborated by the validated integration and its scope — a caller-supplied ID is never trusted
by itself.
- **FR-004**: The system MUST support at least one production-appropriate, replay-resistant
service-to-service authentication mechanism (signed short-lived scoped tokens, OAuth2
client-credentials, or mTLS), selectable per integration.
- **FR-005**: The system MUST support rotating an integration's credential with a defined
transition window in which both the old and new credential are valid, with zero downtime.
- **FR-006**: The system MUST support revoking an integration's credential with immediate effect
on the next request.
- **FR-007**: The system MUST audit every authentication attempt (success and failure), recording
which integration/credential was involved, without ever recording the raw credential value
itself.
- **FR-008**: The system MUST reject any inbound request containing fields outside the defined
contract, rather than silently accepting or ignoring them.
- **FR-009**: The system MUST rate-limit inbound requests per integration and independently per
end user within an integration, with limit values configurable without a deploy.
- **FR-010**: The system MUST distinguish, in its rejection response, between "unregistered/
invalid credential," "suspended product," and "out-of-scope request" where doing so does not
leak whether an unregistered product ID exists in the system.
- **FR-011**: The system MUST let an admin change an integration's status (active/suspended/
deprecated) and have that change take effect on the very next request, without a deploy.
- **FR-012**: The inbound request contract MUST include an optional idempotency-key field,
reserved for the ticketing feature's future use, even though this feature does not implement
deduplication against it.
### Key Entities
- **Product Integration**: One SaaS product's registration with SupportHub — its identity,
current status, credential reference, chosen authentication mechanism, allowed scope, and
rotation/revocation timestamps. Exactly one per product; never shared.
- **Customer Reference**: The external user/tenant identifiers a validated request carries,
scoped to the Product Integration that vouched for them — a reference into the SaaS's own
identity system, never a second copy of it (per Constitution Principle I).
- **Authentication Audit Event**: A record of one authentication attempt (success or failure)
against a Product Integration, including outcome and timestamp, but never the raw credential.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of inbound requests presenting an invalid, unregistered, expired, or
out-of-scope credential are rejected before any downstream record is created.
- **SC-002**: Revoking a credential stops it from being accepted on the very next request after
revocation — no propagation delay beyond normal request processing.
- **SC-003**: Rotating a credential causes zero failed requests for a well-behaved caller using
either the old or new credential during the transition window.
- **SC-004**: An admin can retrieve a complete authentication audit trail (success and failure)
for any given integration on demand.
- **SC-005**: An integration or user sending requests at 10x its configured rate limit is
measurably throttled while unrelated integrations/users see no change in their own success
rate.
- **SC-006**: Onboarding a new SaaS product as an integration requires no code change or
deploy — it is a configuration/data action only.
## Assumptions
- This feature covers the trust boundary and its own admin/audit surface only. It does not
implement ticket creation, the AI agent, or any business logic beyond validating and scoping an
inbound request — those are later features per the roadmap (Phases 3-5+).
- The idempotency key reserved in FR-012 is deliberately not enforced here (no `Ticket` entity
exists yet to deduplicate against) — this is a forward-compatibility placeholder so the
ticketing feature doesn't need a breaking contract change later, per the gap noted in
`docs/11-architect-additions-gaps-and-recommendations.md` §A1.
- "Rate limiting... configurable without a deploy" follows Constitution Principle II
(configuration over hardcoding) — exact default limit values are a
`REQUIRES BUSINESS CONFIRMATION` item per `docs/10-implementation-roadmap.md`, not invented
here.
- Choice of authentication mechanism (signed tokens vs. OAuth2 vs. mTLS) per integration, and the
credential rotation transition-window length, are technical decisions deferred to
`/speckit-plan` — this spec only requires that *a* production-appropriate, replay-resistant
mechanism exists and that rotation/revocation behave as described.
+252
View File
@@ -0,0 +1,252 @@
---
description: "Task list for 002-saas-integration"
---
# Tasks: SaaS Product Integration & Inbound Request Trust
**Input**: Design documents from `specs/002-saas-integration/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md), [contracts/inbound-request-contract.md](./contracts/inbound-request-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Not explicitly requested as TDD in spec.md, but this feature is a security boundary —
unit tests for the token/scope/replay logic and integration tests for the full preHandler are
included as first-class tasks (not optional), since "MUST reject" requirements are exactly what
regressions silently break.
**Organization**: Tasks are grouped by user story (US1 = P1 authenticate/validate, US2 = P2
admin lifecycle, US3 = P3 rate limiting).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
## Path Conventions
Single project. Prisma schema at `prisma/schema.prisma`; new plugin under `src/plugins/`;
extended module under `src/modules/catalog/products/`; tests under `tests/unit/` and
`tests/integration/`.
---
## Phase 1: Setup
- [X] T001 Add `INTEGRATION_CREDENTIAL_ENCRYPTION_KEY` (32-byte, required) to the Zod schema in
`src/config/env.ts`, and add it to `.env.example`, `.env.development`, `.env.test`
- [X] T002 [P] Add `IdempotencyKey`-shaped Zod primitive and shared integration-error codes
(`INVALID_INTEGRATION_CREDENTIAL`, `PRODUCT_INTEGRATION_SUSPENDED`, `REQUEST_OUT_OF_SCOPE`)
to `src/common/constants/app.constants.ts` (or a new `src/common/constants/integration.constants.ts`)
for reuse by both the plugin and the module
**Checkpoint**: Config and shared constants exist for everything below to reference.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: The schema and low-level crypto/verification primitives every user story depends on.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [X] T003 Update `prisma/schema.prisma`: replace the placeholder `Product` model with the
doc-06-aligned shape (`externalProductId`, `supportEnabled`, `status: String`,
`integration ProductIntegration?` relation) per `data-model.md`; keep `Category`, `User`,
`AuditLog` untouched (research.md "Reconciling the placeholder Prisma schema")
- [X] T004 Add `ProductIntegration` model to `prisma/schema.prisma` per `data-model.md`
(depends on T003)
- [X] T005 [P] Add `CustomerReference` model to `prisma/schema.prisma` per `data-model.md`
(depends on T003)
- [X] T006 Update `src/modules/catalog/products/schema/products.schema.ts`'s
`productQuerySchema` to query by `externalProductId` instead of the now-removed `code`
field (depends on T003)
- [X] T007 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T003-T005 (depends on T004, T005, T006)
- [X] T008 [P] Implement the AES-256-GCM encrypt/decrypt helpers (research.md "Credential
storage") in `src/modules/catalog/products/mapper/credential.crypto.ts` — pure functions,
no Prisma/Fastify dependency, so they're independently unit-testable
- [X] T009 [P] Implement signed-token issue/verify helpers (HMAC-SHA256 JWT: `productId`,
`tenantId`, `userId`, `iat`, `exp`, `jti`) in
`src/modules/catalog/products/mapper/integration-token.ts` (depends on T008 for how the
per-integration secret is obtained, but the signing/verification logic itself has no
Prisma dependency)
- [X] T010 [P] Implement the replay-check helper (`hasSeenJti` / `markJtiSeen`, TTL-bound) in
`src/infrastructure/cache/` using the existing `cacheService` abstraction
(`src/infrastructure/cache/cache.service.ts`)
**Checkpoint**: Schema migrated; crypto/token/replay primitives exist and are independently unit
tested (see Phase 3). User Story 1's plugin can now be wired up.
---
## Phase 3: User Story 1 - Every inbound request is authenticated and trusted before anything happens (Priority: P1) 🎯 MVP
**Goal**: A `product-integration-auth` Fastify plugin validates every inbound request through the
9-step order in `contracts/inbound-request-contract.md`, populating `request.reqContext` only on
full success, and audit-logging every attempt.
**Independent Test**: Quickstart Scenarios 1-4 (valid request accepted; invalid/unregistered
credential rejected identically; suspended product distinguishably rejected; unknown field
rejects the whole request).
### Tests for User Story 1
- [X] T011 [P] [US1] Unit tests for `credential.crypto.ts` (encrypt/decrypt round-trip, wrong key
fails) in `tests/unit/products/credential-crypto.test.ts`
- [X] T012 [P] [US1] Unit tests for `integration-token.ts` (valid token verifies; tampered
signature rejected; expired token rejected; clock-skew tolerance boundary) in
`tests/unit/products/integration-token.test.ts`
- [X] T013 [P] [US1] Integration test for the full preHandler covering Quickstart Scenarios 1-4
against a real Postgres/Redis in `tests/integration/product-integration-auth.test.ts`
### Implementation for User Story 1
- [X] T014 [US1] Add `ProductIntegrationsRepository` (Prisma-backed: find by product, find
active by internal id, update rotation/revocation fields) in
`src/modules/catalog/products/repository/product-integrations.repository.ts` (depends on
T007)
- [X] T015 [US1] Add `CustomerReferencesRepository` (find-or-create by
`externalUserId`+`externalTenantId`) in
`src/modules/catalog/products/repository/customer-references.repository.ts` (depends on
T007)
- [X] T016 [US1] Define the strict inbound request Zod schema (`.strict()`, per
`data-model.md`'s Inbound Request Contract table, including the reserved `idempotencyKey`)
in `src/modules/catalog/products/schema/inbound-request.schema.ts` (depends on T002)
- [X] T017 [US1] Implement `product-integration-auth.plugin.ts` in `src/plugins/`: runs the
9-step validation order from `contracts/inbound-request-contract.md`, using T009/T010/T014
/T015/T016, populating `request.reqContext` (`productId`, `customerId`, `tenantId`,
`actorType: CUSTOMER`, `actorId`) only after every step passes (depends on T014, T015,
T016)
- [X] T018 [US1] Write one `AuditLog` row per attempt (success or every failure reason) inside
the plugin, per the `AuditLog` field mapping in `data-model.md` — never including the raw
token/credential (depends on T017)
- [X] T019 [US1] Register `product-integration-auth.plugin.ts` in
`src/bootstrap/plugins.bootstrap.ts`, scoped only to the inbound SaaS-facing route (not
global) (depends on T017)
- [X] T020 [US1] Run Quickstart Scenarios 1-4 locally against a seeded integration and confirm
all four pass
**Checkpoint**: User Story 1 is fully functional and independently testable — the trust boundary
exists and correctly accepts/rejects/distinguishes every case in scope.
---
## Phase 4: User Story 2 - An admin can onboard, rotate, and revoke a product's integration credential (Priority: P2)
**Goal**: Admin endpoints to register/rotate/revoke a `ProductIntegration` and retrieve its audit
trail, reusing T008/T014 from Phase 2/3.
**Independent Test**: Quickstart Scenarios 5-7 (rotation is zero-downtime, revocation is
immediate, audit trail is retrievable).
### Tests for User Story 2
- [X] T021 [P] [US2] Integration tests for register/rotate/revoke/get-audit-trail endpoints in
`tests/integration/product-integrations-admin.test.ts`, covering Quickstart Scenarios 5-7
### Implementation for User Story 2
- [X] T022 [US2] Add `ProductIntegrationsService` methods (`register`, `rotate`, `revoke`,
`updateStatus`, `getAuditTrail`) in
`src/modules/catalog/products/service/product-integrations.service.ts``register`/
`rotate` generate a new secret, encrypt it (T008) before persisting, and return the
plaintext secret in the response exactly once (depends on T014)
- [X] T023 [US2] Add `ProductIntegrationsController` with admin-authenticated handlers
(register/rotate/revoke/updateStatus/getAuditTrail) in
`src/modules/catalog/products/controller/product-integrations.controller.ts`, gated by the
existing `fastify.authenticate` (human/admin JWT, `auth.plugin.ts`) — not the
product-integration plugin from Phase 3 (depends on T022)
- [X] T024 [US2] Add routes (`POST /admin/products/:id/integration`, `POST
/admin/products/:id/integration/rotate`, `POST /admin/products/:id/integration/revoke`,
`PATCH /admin/products/:id/integration/status`, `GET
/admin/products/:id/integration/audit-trail`) in
`src/modules/catalog/products/routes/product-integrations.routes.ts`, registered from
`src/modules/catalog/products/routes/index.ts` (depends on T023)
- [X] T025 [US2] Run Quickstart Scenarios 5-7 locally and confirm all three pass
**Checkpoint**: Both Stories 1 and 2 work together — an admin can onboard an integration and User
Story 1's plugin correctly validates against whatever the admin configured.
---
## Phase 5: User Story 3 - No single product integration or end user can overwhelm the system (Priority: P3)
**Goal**: Per-integration and per-user rate limiting on the inbound route, using
`ProductIntegration.rateLimitPerMinute`/`rateLimitPerUserPerMinute`.
**Independent Test**: Quickstart Scenario 8.
### Implementation for User Story 3
- [X] T026 [US3] Add a per-route `@fastify/rate-limit` registration (integration-level
`keyGenerator`) plus a second, stricter one (user-level `keyGenerator`) on the inbound
route, reading limits from `request.reqContext`-resolved `ProductIntegration` fields, in
`src/plugins/rate-limit.plugin.ts` (keep the existing global registration untouched) —
depends on T017 populating `reqContext` before the rate-limit check runs
- [X] T027 [P] [US3] Integration test covering Quickstart Scenario 8 (integration-level and
user-level throttling, unrelated integration/user unaffected) in
`tests/integration/inbound-rate-limit.test.ts`
- [X] T028 [US3] Run Quickstart Scenario 8 locally and confirm it passes
**Checkpoint**: All three user stories work independently and together.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T029 [P] Add a short "Implemented in 002-saas-integration" note to
`specs/002-saas-integration/checklists/requirements.md` Notes once all scenarios pass
(`docs/06-database-schema.md` itself is the source spec and is intentionally not edited)
- [X] T030 [P] Add a "SaaS Integration" section to `README.md` describing the inbound contract at
a high level and linking to `specs/002-saas-integration/quickstart.md`
- [X] T031 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` to
confirm the new module/plugin code respects existing module-boundary rules
- [X] T032 Full regression: `npm run test:unit` (which currently runs the whole suite — see
`specs/001-ci-pipeline/checklists/requirements.md` implementation notes) to confirm nothing
in catalog/products or the plugin chain broke existing tests
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories (schema + crypto/token/
replay primitives are shared by every story)
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2/US3
- **User Story 2 (Phase 4)**: Depends on Foundational (T014) — independent of US1's plugin, but
practically sequenced after US1 so there's something to validate against when testing rotation
- **User Story 3 (Phase 5)**: Depends on US1's `reqContext` population (T017) — genuinely not
implementable before US1, since rate-limit keys need the validated integration/user identity
- **Polish (Phase 6)**: Depends on all three user stories
### Parallel Opportunities
- T001/T002 (Setup)
- T005 alongside T004 (different models, same file — coordinate to avoid edit conflicts even
though marked [P])
- T008/T009/T010 (independent primitives)
- T011/T012/T013 (independent test files) once their subjects exist
- T021 can be written in parallel with Phase 3's later tasks once T014 exists
- T029/T030 in Polish
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Setup + Foundational (T001-T010)
2. User Story 1 (T011-T020)
3. **STOP and VALIDATE**: Quickstart Scenarios 1-4 pass — the trust boundary itself is complete
and demoable even before admin tooling or rate limiting exist (a seeded integration is enough)
### Incremental Delivery
1. Setup + Foundational → schema migrated, primitives tested
2. Add User Story 1 → inbound requests are authenticated (MVP)
3. Add User Story 2 → integrations can be onboarded/rotated/revoked without touching the DB by
hand
4. Add User Story 3 → abuse-resistant
5. Polish → docs and full regression
@@ -0,0 +1,69 @@
# Specification Quality Checklist: Ticket Creation, Messages & Attachments
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-02
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Scope is deliberately Phase 5 only (per docs/10-implementation-roadmap.md): ticket/problem
creation, messages, attachments, lifecycle state machine. Investigation/root cause/solution/
resolution (Phase 9) and AI diagnosis (Phase 4) are explicitly out of scope — see Assumptions.
- Recurring-problem matching is intentionally left to an explicit caller-supplied reference for
this feature; real fuzzy/semantic matching is deferred to the future AI-support feature.
- Malware-scanner choice and RLS adoption are left to `/speckit-plan` / business confirmation
respectively, not decided here.
- All items pass; no revision iterations were needed.
## Implementation notes (added during /speckit-implement)
- **Found and fixed a real cross-product ticket-code collision bug**: `deriveProductCode`
truncates to 4 alphabetic characters, so different products can legitimately derive the same
prefix (e.g. every test product in this repo's own test suite starts with `TEST...`, all
deriving `"TEST"`). The initial sequence-counting query (`countForProductAndYear`) was scoped
by internal `productId`, but the `code` column's uniqueness is global — two different products
sharing a prefix would each independently compute sequence `1` and collide. Fixed by rescoping
the count to the actual code prefix (`countForCodePrefix`, `WHERE code LIKE 'PREFIX-YEAR-%'`),
which correctly reflects what the unique constraint actually guards. The existing retry-on-
conflict loop (`isTicketCodeConflict`, `MAX_CODE_RETRIES`) still exists as the concurrency
backstop for the rare race between two concurrent creates computing the same count-based
sequence simultaneously — confirmed exercising this retry path for real during the verification
run below (visible as caught-and-retried `P2002` errors in the test log, not test failures).
- **Found and fixed a second-order issue this feature introduces for the existing 002-saas-
integration test suite**: three of its integration tests' `afterAll` cleanup deleted
`ProductIntegration` then `Product` directly. Now that a successful `/v1/support/requests` call
also creates a `Ticket`/`Problem` (this feature), deleting the `Product` first failed on the
`problems_productId_fkey` RESTRICT constraint. Fixed by adding `ticketMessage`/`ticket`/
`problem` cleanup before the existing steps in
`tests/integration/product-integration-auth.test.ts`,
`tests/integration/product-integrations-admin.test.ts`, and
`tests/integration/inbound-rate-limit.test.ts`.
- All 9 integration test files (24 tests total, spanning both this feature and the pre-existing
002-saas-integration suite) were run and passed against a real Postgres, Redis, and MinIO
(temporary Docker containers) — including a real presigned-PUT upload and presigned-GET
download round-trip against MinIO, not a mock.
@@ -0,0 +1,61 @@
# Contract: Ticket Lifecycle, Messages & Attachments
## Ticket creation (via the inbound trust boundary)
`POST /v1/support/requests` (002-saas-integration) now, after successful auth:
1. Resolve/create `Problem` (research.md's explicit-reference-only rule).
2. Atomic create-or-fetch `Ticket` on `(productId, idempotencyKey)` — a retried request returns
the same ticket, never a second one (FR-004/SC-002).
3. Write a `SYSTEM_EVENT` message.
4. Respond `202` with `{ ticketId, code, status, problemId }`.
**Guarantee**: no request that passes the trust boundary ever completes without a ticket existing
(FR-001/SC-001) — ticket creation is synchronous within the same request, not queued.
## Ticket status transitions
`PATCH /tickets/:ticketId/status` — body `{ status: <new status>, expectedVersion: <int> }`.
| Step | Failure |
|---|---|
| Ticket exists and caller is tenant-authorized | `404` / `403` |
| `expectedVersion` matches the ticket's current `version` | `409 CONFLICT` (FR-007/SC-007) — caller must re-read and retry |
| Requested transition is a valid edge from the current status (research.md's table) | `400 INVALID_TRANSITION` |
On success: `status` and `version` (+1) update atomically; a `SYSTEM_EVENT` message records the
transition.
## Messages
- `POST /tickets/:ticketId/messages` — body `{ type, body }` (`authorRef`/`visibleToCustomer`
derived server-side, never accepted as input — FR-008).
- `GET /tickets/:ticketId/messages` — the caller's scope (customer vs. agent/admin) determines
which types are queried; a customer-scoped caller's query never includes
`visibleToCustomer: false` rows (FR-009) — enforced in the repository's `WHERE` clause, not by
filtering an already-fetched list.
## Attachments
1. `POST /tickets/:ticketId/attachments/upload-url` — body `{ fileName, mimeType, sizeBytes }`,
validated against configured limits (FR-012) before a presigned PUT URL is returned, along
with the `storageKey` the caller must echo back in step 3. No `TicketAttachment` row exists
yet at this point.
2. Caller PUTs the file directly to the returned URL (file bytes never transit this API).
3. `POST /tickets/:ticketId/attachments/confirm` — body
`{ storageKey, fileName, mimeType, sizeBytes }` (echoing step 1's values) — creates the
`TicketAttachment` row (`scanStatus: pending`) and enqueues the scan job on
`attachments-queue`. No `attachmentId` exists before this call, so it isn't a path param here.
4. `GET /tickets/:ticketId/attachments/:attachmentId/download-url` — returns a presigned GET URL
only if `scanStatus == 'clean'`; otherwise `409` with the current scan status (FR-013/FR-014).
## Guarantees (callable contract)
1. **Ticket existence is synchronous with trust-boundary success** — never eventually-consistent.
2. **Idempotency key reuse never creates a second ticket**, regardless of retry count (SC-002).
3. **No internal-only message type is ever returned to a customer-scoped read**, verified per
type (SC-003).
4. **No attachment file byte ever reaches PostgreSQL** — only `storageKey` metadata (SC-004).
5. **No attachment is downloadable before `scanStatus: clean`**, every time it's attempted
(SC-005).
6. **A concurrent, stale-version status update is rejected, never silently overwritten** (SC-007).
+100
View File
@@ -0,0 +1,100 @@
# Phase 1 Data Model: Ticket Creation, Messages & Attachments
All new models use `cuid()` ids, matching the convention established in 002-saas-integration.
Relations to not-yet-existing models (AI sessions, assignments, SLA runs, escalation events,
investigations, root causes, solutions — all later phases) are deliberately omitted for now and
added when those phases introduce the models they'd point to; Prisma can't reference a model that
doesn't exist.
## Ticket
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| code | String @unique | `<PRODUCT_CODE>-<YEAR>-<SEQUENCE>` (research.md) |
| productId | String | FK → `Product` |
| problemId | String | FK → `Problem` |
| customerId | String | FK → `CustomerReference` (from 002-saas-integration) — the normalized reference |
| externalUserId | String | Denormalized copy, matches doc 06's literal shape for query convenience without a join |
| externalTenantId | String | Denormalized copy, same rationale |
| status | String | One of the 12 states in research.md's state machine; `@default("NEW")` |
| priority | String | Free-text for now — `PriorityPolicy`-driven derivation is Phase 6/orchestration, not this feature |
| severity | String | |
| categoryId | String? | FK → existing `Category` model (already in schema from the original scaffold) |
| idempotencyKey | String? | Research.md's idempotency mechanism |
| version | Int @default(1) | Optimistic concurrency (research.md) |
| createdAt / updatedAt | DateTime | |
**Constraints**: `@@unique([productId, idempotencyKey])` (nullable-excluded — two tickets with
`idempotencyKey: null` don't conflict). Index on `(productId, status)` and
`(externalTenantId, externalUserId)` for the query patterns FR-015 requires (tenant/user-scoped
lookups).
**Status transition rule**: enforced entirely in the service layer against the explicit adjacency
table in research.md — the column itself has no DB-level CHECK constraint beyond "is a known
string," since Prisma doesn't model state machines natively and a CHECK constraint would need to
be duplicated in code anyway for the "attempted from X" half of transition validation.
## Problem
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| statement | String | |
| symptoms | String | |
| impact | String? | |
| productId | String | FK → `Product` |
| categoryId | String? | FK → existing `Category` model |
| severity | String | |
| customerImpact | String? | |
| businessImpact | String? | |
| environment | String? | |
| createdAt | DateTime @default(now()) | |
**Relations added by this feature**: `tickets Ticket[]` (1 problem : many tickets, Constitution
Principle VIII).
## TicketMessage
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → `Ticket` |
| type | String | `CUSTOMER_MESSAGE` \| `AI_MESSAGE` \| `AGENT_MESSAGE` \| `INTERNAL_NOTE` \| `SYSTEM_EVENT` \| `INVESTIGATION_NOTE` \| `SOLUTION_NOTE` |
| authorRef | String | `agentId`, `"ai"`, `"system"`, or `externalUserId` — never a local FK (Constitution Principle I) |
| body | String | |
| visibleToCustomer | Boolean | Set from the type→visibility map at write time (research.md) — **never** accepted as request input |
| createdAt | DateTime @default(now()) | |
**Index**: `(ticketId, visibleToCustomer, createdAt)` — the exact shape customer-scoped reads
query on (FR-009).
## TicketAttachment
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| ticketId | String | FK → `Ticket` |
| storageKey | String | S3/MinIO object key — never the file itself (FR-011) |
| fileName | String | Original filename, for display only |
| mimeType | String | Validated against an allow-list at upload-confirm time (FR-012) |
| sizeBytes | Int | Validated against a configured max at upload-confirm time |
| scanStatus | String | `pending` \| `clean` \| `infected` \| `rejected` (research.md's `MalwareScanner`) |
| uploadedBy | String | `agentId` or `externalUserId` — same non-FK convention as `TicketMessage.authorRef` |
| createdAt | DateTime @default(now()) | |
**Download rule**: a presigned GET URL is generated only when `scanStatus == 'clean'` — enforced
in the service layer before calling `storageService.getPresignedUrl`, never left to the caller to
check first (FR-013/FR-014).
## Inbound Request → Ticket Creation (behavior, not a new table)
Extends 002-saas-integration's inbound flow. After `authenticateProductIntegration` succeeds and
populates `request.reqContext`, the route handler (previously a stub echoing context back) now:
1. Resolves or creates a `Problem` (research.md's explicit-reference-only linking).
2. Atomically creates (or, on idempotency-key conflict, fetches) the `Ticket` in `NEW` status.
3. Writes a `SYSTEM_EVENT` `TicketMessage` recording the creation (Constitution Principle VI —
durable audit trail via the message timeline itself).
4. Returns the ticket's `id`/`code`/`status` to the caller (still `202`, now backed by a real
record instead of an echo).
+139
View File
@@ -0,0 +1,139 @@
# Implementation Plan: Ticket Creation, Messages & Attachments
**Branch**: `003-ticketing` | **Date**: 2026-09-02 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/003-ticketing/spec.md`
## Summary
Add the `Ticket`/`Problem`/`TicketMessage`/`TicketAttachment` domain: creating a ticket (and its
problem) immediately from a validated inbound request (wiring into 002-saas-integration's
`POST /v1/support/requests`, which today only echoes trusted context back), a typed message
timeline with enforced internal-note privacy, and an attachment pipeline built on the
already-scaffolded S3-compatible `storageService` plus a new async malware-scan job on the
already-scaffolded `attachments-queue`. Idempotency-key enforcement (deferred from
002-saas-integration's FR-012) is implemented here since `Ticket` now exists.
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), BullMQ (new attachment-scan job, using the
existing `attachments-queue` and `QueueManager`), `@aws-sdk/client-s3` +
`@aws-sdk/s3-request-presigner` (already wired via `storageService` — no new dependency), Zod.
**Storage**: PostgreSQL via Prisma (new `Ticket`, `Problem`, `TicketMessage`, `TicketAttachment`
models per `docs/06-database-schema.md`) + S3-compatible object storage (AWS S3 in
test/production, MinIO locally — per the user's confirmed choice, matching doc 04's explicit
guidance and the existing `storageConfig`/`storageService` scaffold).
**Testing**: Vitest — unit tests for the state-machine transition table and message-visibility
mapping; integration tests for ticket creation (incl. idempotency and recurring-problem linking),
message read-scoping, and the attachment upload → scan → download flow, against a real
Postgres/Redis/S3-compatible target (MinIO via `docker-compose.test.yml`, extended by this
feature — see research.md).
**Target Platform**: Same Fastify modular monolith. Extends the already-scaffolded
`src/modules/ticketing/{tickets,messages,attachments}` modules (currently: `tickets` has a bare
`GET /tickets` stub; `messages`/`attachments` are unimplemented skeletons).
**Project Type**: Backend service — single project.
**Performance Goals**: Ticket creation (FR-001) must complete synchronously within the inbound
request's own response — no async/eventual-consistency gap between "request accepted" and
"ticket exists" (this is the whole point of Constitution Principle "ticket created immediately").
Malware scanning is explicitly asynchronous (SC-005 only requires it's enforced *before
download*, not before upload completes).
**Constraints**: MUST NOT store attachment file bytes in PostgreSQL (FR-011); MUST NOT make an
attachment downloadable before its scan clears (FR-013); ticket status updates MUST use
optimistic concurrency (FR-007); every ticket/message/attachment query MUST be tenant-scoped
(FR-015).
**Scale/Scope**: One inbound-flow change (002-saas-integration's stub handler becomes real ticket
creation), full CRUD-ish surface for messages or a customer/agent to read, and an attachment
upload/download surface. Explicitly excludes investigation/root-cause/solution/resolution
(Phase 9) and AI diagnosis (Phase 4) per 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 | Ticket/message/attachment queries are scoped by the already-validated `reqContext` (productId/customerId/tenantId) from 002-saas-integration's trust boundary — never a caller-supplied id alone (FR-015). | PASS |
| II. Configuration Over Hardcoding | Message-type visibility mapping, attachment size/type limits, and scan-status gate are all defined as data/config, not scattered conditionals — see data-model.md. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | Extends the existing `tickets`/`messages`/`attachments` modules through their own controller/service/repository layers and public `index.ts`; the scan job lives in `src/jobs/attachments/` per the existing scaffold, calling the `attachments` module's repository through its public API only. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI in this feature (explicitly deferred to Phase 4). | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — resolution/verification is Phase 9. | PASS — N/A |
| VI. Durable Audit & History | Ticket status transitions and attachment scan-status changes are written as `SYSTEM_EVENT` ticket messages (visible per FR-008's type-driven visibility), giving a durable, queryable history without a separate audit mechanism for this feature's own state changes. | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Ticket status updates use optimistic concurrency (a `version` column, FR-007); the malware-scan job is idempotent (re-running it for an already-scanned attachment is a no-op); idempotency-key enforcement (FR-004) reuses the same atomic-upsert pattern as 002-saas-integration's `CustomerReference.findOrCreate`. | PASS |
| VIII. Problem and Ticket Are Separate, Related Entities | Directly implements this principle (FR-003) — this is the feature that first creates both entities. | PASS |
| Technology & Platform Constraints | Uses only already-present dependencies (Prisma, BullMQ, AWS SDK, Zod) — no new runtime dependency. | PASS |
No violations requiring Complexity Tracking justification.
## Post-Design Constitution Re-check
All gates above remain PASS after Phase 1 design. One design detail worth calling out: the
malware scanner (research.md) fails closed (defaults to `infected`, never `clean`) precisely
because Principle V's spirit ("evidence-based, not assumed") applies here even though this
feature's own scope is pre-Phase-9 — an attachment pipeline that silently marked everything
"clean" would be asserting a safety property with no evidence behind it.
## Project Structure
### Documentation (this feature)
```text
specs/003-ticketing/
├── 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 Ticket, Problem, TicketMessage,
│ TicketAttachment models per docs/06
├── src/
│ ├── modules/
│ │ ├── catalog/products/
│ │ │ └── routes/inbound-request.routes.ts # MODIFIED — actually create a ticket instead
│ │ │ of echoing context back
│ │ └── ticketing/
│ │ ├── tickets/ # EXTENDED (existing scaffold) — create/get/
│ │ │ ├── controller/ list/updateStatus, state machine, idempotency
│ │ │ ├── service/
│ │ │ ├── repository/
│ │ │ ├── routes/
│ │ │ ├── schema/
│ │ │ ├── mapper/
│ │ │ └── types/
│ │ ├── messages/ # EXTENDED (existing scaffold) — post/list,
│ │ │ └── ... visibility-scoped reads
│ │ └── attachments/ # EXTENDED (existing scaffold) — upload
│ │ └── ... (presign + confirm), scan-gated download
│ └── jobs/
│ └── attachments/ # EXTENDED (existing scaffold) — real malware
│ └── index.ts scan worker (pluggable scanner, see research.md)
└── tests/
├── unit/ticketing/ # state machine, visibility mapping
└── integration/ # creation/idempotency, messages, attachments
```
**Structure Decision**: Single project, extending the three already-scaffolded `ticketing`
submodules rather than restructuring them — `docs/07-backend-architecture.md`'s module layout
already anticipated exactly this shape. The inbound-request handler from 002-saas-integration is
modified in place (it's the one integration point between "request trusted" and "ticket exists"),
not duplicated.
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+56
View File
@@ -0,0 +1,56 @@
# Quickstart: Validating Ticket Creation, Messages & Attachments
Prerequisites: 002-saas-integration's inbound trust boundary working (a seeded/registered
`ProductIntegration`), MinIO running locally (research.md), migrations applied.
## Scenario 1 — a trusted request creates a ticket immediately (User Story 1)
1. Send a valid inbound request through `POST /v1/support/requests`.
2. **Expected**: `202` with a `ticketId`/`code`/`status: NEW`; the `Ticket` and its `Problem`
exist in the database immediately — no polling needed.
## Scenario 2 — idempotency key prevents duplicate tickets (User Story 1)
1. Send the same request twice with the same `idempotencyKey`.
2. **Expected**: both responses reference the *same* `ticketId`; only one `Ticket` row exists.
## Scenario 3 — recurring problem links to the existing Problem (User Story 1)
1. Create a ticket, note its `problemId`.
2. Send a second, distinct request whose `referenceIds` includes that same problem's reference.
3. **Expected**: the new ticket has the *same* `problemId` as the first — no second `Problem`
created.
## Scenario 4 — internal notes never leak to a customer-scoped read (User Story 2)
1. Post one message of each type (`CUSTOMER_MESSAGE`, `AI_MESSAGE`, `AGENT_MESSAGE`,
`INTERNAL_NOTE`, `SYSTEM_EVENT`, `INVESTIGATION_NOTE`, `SOLUTION_NOTE`) to a ticket.
2. Read the ticket's messages through a customer-scoped call.
3. **Expected**: only `CUSTOMER_MESSAGE`, `AI_MESSAGE`, `AGENT_MESSAGE`, `SYSTEM_EVENT` appear —
the other three are absent entirely, not present-but-flagged.
4. Read the same ticket's messages through an agent-scoped call.
5. **Expected**: all seven messages appear.
## Scenario 5 — an attachment is unusable until it clears scanning (User Story 3)
1. Request an upload URL, PUT a file, confirm the upload.
2. Immediately request a download URL.
3. **Expected**: `409``scanStatus` is `pending`.
4. Wait for the scan job to run (with the placeholder scanner, research.md — it fails closed to
`infected`).
5. Request a download URL again.
6. **Expected**: still refused — `scanStatus: infected` — confirming the pipeline correctly gates
on a real (even if placeholder) scan result rather than defaulting to available.
## Scenario 6 — a stale ticket-status update is rejected, not overwritten (Edge Cases)
1. Read a ticket's current `status`/`version`.
2. In two separate calls, attempt two different valid status transitions using the *same*
`expectedVersion`.
3. **Expected**: exactly one succeeds; the other receives `409 CONFLICT` and must re-read the
ticket to retry.
## What "done" looks like
All six 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.
+156
View File
@@ -0,0 +1,156 @@
# Phase 0 Research: Ticket Creation, Messages & Attachments
## Decision: Ticket code format
- **Decision**: `<PRODUCT_CODE>-<YEAR>-<SEQUENCE>`, e.g. `DQB-2026-00567` (matches doc 01/04's own
example exactly). `PRODUCT_CODE` is derived from the `Product.externalProductId` (first
alphabetic segment, uppercased, max 4 chars, falling back to a generic prefix if the external
id doesn't yield one cleanly) and `SEQUENCE` is a per-product-per-year monotonic counter.
- **Rationale**: Doc 01/04 use this exact shape as the running example throughout the guide;
matching it keeps generated codes recognizable against the spec's own illustrations. A
per-product-per-year counter (not a global one) keeps codes short and stable even as ticket
volume grows across many products.
- **Alternatives considered**: A UUID-derived short code — rejected, not human-referenceable the
way doc 01's own example implies support agents need ("DQB-2026-00567" is meant to be readable
and speakable, not just unique).
## Decision: Ticket lifecycle state machine
- **Decision**: States, exactly as doc 04 §1 and doc 06's `Ticket.status` comment list them:
`NEW, AI_ANALYZING, AI_TROUBLESHOOTING, AI_VERIFYING, AI_RESOLVED, HUMAN_ESCALATION,
IN_PROGRESS, WAITING_FOR_CUSTOMER, RESOLUTION_PENDING_CUSTOMER, RESOLVED, CLOSED, REOPENED`.
Valid transitions are defined as an explicit adjacency table (not "anything can go anywhere"):
- `NEW``AI_ANALYZING`, `HUMAN_ESCALATION`
- `AI_ANALYZING``AI_TROUBLESHOOTING`, `HUMAN_ESCALATION`
- `AI_TROUBLESHOOTING``AI_VERIFYING`, `HUMAN_ESCALATION`
- `AI_VERIFYING``AI_RESOLVED`, `HUMAN_ESCALATION`
- `AI_RESOLVED``RESOLUTION_PENDING_CUSTOMER`, `RESOLVED`, `HUMAN_ESCALATION` (verification
failure re-escalating, per doc 04 §7)
- `HUMAN_ESCALATION``IN_PROGRESS`
- `IN_PROGRESS``WAITING_FOR_CUSTOMER`, `RESOLUTION_PENDING_CUSTOMER`, `HUMAN_ESCALATION`
(re-escalation)
- `WAITING_FOR_CUSTOMER``IN_PROGRESS`
- `RESOLUTION_PENDING_CUSTOMER``RESOLVED`, `IN_PROGRESS` (customer disputes, reopens
investigation per doc 04 §7)
- `RESOLVED``CLOSED`, `REOPENED`
- `CLOSED``REOPENED`
- `REOPENED``IN_PROGRESS`, `AI_ANALYZING`
- **Rationale**: An explicit table is what makes FR-006 ("only valid transitions") actually
enforceable and testable, rather than a status field anyone can set to anything. The specific
edges are read directly off doc 04's described flows (§1 happy path, §3 human flow, §7
verification-failure branches, §9 resolve/close/reopen).
- **Alternatives considered**: No enforced table (any transition allowed) — rejected, directly
contradicts FR-006; a generic workflow-engine library — rejected as disproportionate for a
fixed, spec-defined state set with no per-tenant customization need at this phase.
## Decision: Ticket status optimistic concurrency
- **Decision**: `Ticket` gains an `Int` `version` column (default `1`, matching doc 06's general
guidance to add this to mutable shared-state tables). A status update runs
`UPDATE tickets SET status = ?, version = version + 1 WHERE id = ? AND version = ?`; zero rows
affected means the caller's view was stale, and the update is rejected (`409 CONFLICT`, caller
must re-read and retry).
- **Rationale**: This is the standard optimistic-locking pattern and satisfies FR-007/SC-007
without needing row-level locks or a distributed lock service — Postgres's own atomic
`UPDATE ... WHERE` already guarantees exactly one concurrent writer wins.
- **Alternatives considered**: `SELECT ... FOR UPDATE` pessimistic locking — rejected, holds a
transaction open across what could be a slow caller round-trip; optimistic locking only holds
the lock for the single atomic statement.
## Decision: Idempotency-key enforcement
- **Decision**: `Ticket` gains a nullable, unique-per-product `idempotencyKey` column
(`@@unique([productId, idempotencyKey])`, nullable values excluded from the constraint per
Postgres's standard NULL-handling). Ticket creation is an atomic
`INSERT ... ON CONFLICT (productId, idempotencyKey) DO NOTHING RETURNING *`-style upsert; a
conflict means the key was already used, and the existing ticket is looked up and returned
instead.
- **Rationale**: Same atomic-upsert pattern already used for `CustomerReference.findOrCreate` in
002-saas-integration — proven, race-safe under concurrent retries, no separate idempotency-key
cache/table needed. Scoped per-product (not globally unique) because two different products'
clients could coincidentally generate the same key value.
- **Alternatives considered**: A separate `IdempotencyKey` tracking table — rejected as
unnecessary indirection when the key can live directly on the row it's deduplicating.
## Decision: Recurring-problem linking
- **Decision**: Per spec.md's Assumptions, this feature links to an existing `Problem` only when
the inbound request's `referenceIds` (docs/02 §3) or the ticket-creation call explicitly
supplies a known prior ticket/problem id; otherwise a new `Problem` is always created. No
fuzzy/semantic matching is attempted here.
- **Rationale**: Real recurring-problem detection needs product-aware understanding of symptoms —
that's the AI-support feature's job (Phase 4), not this one's. Building a heuristic here would
either be too naive to be useful or scope-creep into Phase 4's actual responsibility.
- **Alternatives considered**: Simple text-similarity matching on `Problem.statement` — rejected,
would produce false-positive links (different problems, similar wording) with no way to correct
them until Phase 4 exists to do it properly.
## Decision: Message type → visibility mapping
- **Decision**: A single source-of-truth constant map (not per-message logic):
```
CUSTOMER_MESSAGE: true, AI_MESSAGE: true, AGENT_MESSAGE: true, SYSTEM_EVENT: true,
INTERNAL_NOTE: false, INVESTIGATION_NOTE: false, SOLUTION_NOTE: false
```
`TicketMessage.visibleToCustomer` (doc 06's own field) is *set from this map at write time*,
never accepted as caller input — and customer-scoped reads filter `WHERE visibleToCustomer =
true` at the repository/query layer, not by trimming the response after fetching everything.
- **Rationale**: Directly satisfies FR-008 (visibility derived from type, not independently
settable) and FR-009 (enforced at the query layer, so a serialization bug can't leak a note that
was never fetched in the first place).
- **Alternatives considered**: Accepting `visibleToCustomer` as an API input — rejected outright,
this is exactly the "trust the client" mistake FR-008 exists to prevent.
## Decision: Attachment pipeline shape
- **Decision**: Two-phase upload — (1) caller requests a presigned PUT URL for a given
filename/content-type (`storageService` already has the S3 client wired, needs a presigned-PUT
method added alongside its existing presigned-GET `getPresignedUrl`); (2) caller PUTs the file
directly to object storage, then confirms the upload, which creates the `TicketAttachment` row
(`scanStatus: pending`) and enqueues a scan job on the existing `attachments-queue`
(`src/jobs/attachments/index.ts`, currently a log-only stub). Downloads use the existing
`storageService.getPresignedUrl` (GET), but only after the repository confirms
`scanStatus: clean` — the presigned URL is never generated for a `pending`/`infected`/`rejected`
attachment.
- **Rationale**: A presigned-PUT upload means file bytes never transit the Fastify process at all
(satisfies FR-011 more strongly than a server-side proxy-upload would, and avoids adding
multipart-body handling to this feature). The existing `attachments-queue` scaffold is exactly
where the scan step belongs per doc 07's own module layout.
- **Alternatives considered**: Server-side proxy upload (client → Fastify → S3) — rejected as
unnecessary complexity/latency when presigned PUT achieves the same security properties with
less code.
## Decision: Malware scanning — no scanner exists in this stack yet
- **Decision**: Define a small `MalwareScanner` interface (`scan(objectKey): Promise<'clean' |
'infected'>`) and ship one implementation now: a clearly-named
`UnimplementedPlaceholderScanner` that always returns `'infected'` (fails closed, never
`'clean'`) and logs a loud warning — so attachments are correctly gated as never-downloadable
until a real scanner (e.g. ClamAV via a sidecar, or a cloud provider's scanning API) is wired
in as a follow-up. The job worker calls whichever implementation is bound at startup.
- **Rationale**: No antivirus/scanning service exists anywhere in this codebase or its
dependencies, and standing one up (e.g. deploying ClamAV) is real infrastructure work outside
this feature's scope. The alternative — a scanner that always returns `'clean'` — would satisfy
the code path but silently defeat FR-013's actual security purpose; failing closed means the
pipeline is honest about "attachments aren't actually safe to download yet" rather than
pretending they are. This mirrors the same honesty principle as the credential-storage
placeholder decision in 002-saas-integration's research.md.
- **Alternatives considered**: Always-`'clean'` stub — rejected, defeats the feature's own
purpose and would be easy to forget to replace since nothing would ever surface the gap.
Skipping the scan step's implementation entirely (leave the job as its current log-only stub)
— rejected, FR-013 requires attachments be gated on scan status, and a permanently-`pending`
attachment (nothing ever calls the job) makes attachments unusable rather than correctly gated.
## Decision: Local/test object storage — add MinIO to Docker Compose
- **Decision**: Add a `minio` service to `docker-compose.test.yml` and
`docker-compose.development.yml`, and set `AWS_S3_ENDPOINT` in `.env.test`/`.env.development`
to point at it. `storageClient.ts` already branches on `storageConfig.endpoint` being set
(`forcePathStyle: true` for MinIO compatibility) — no client code changes needed, only compose
wiring and env values.
- **Rationale**: Doc 04 explicitly calls for "MinIO for local dev," and the client already
anticipated this (the `endpoint`/`forcePathStyle` branch exists in code that predates this
feature) — this decision just finishes wiring what was already half-built.
- **Alternatives considered**: Mocking S3 calls in tests instead of running real MinIO — rejected,
this feature's whole point includes verifying presigned URLs actually work and expire, which a
mock can't meaningfully verify.
+237
View File
@@ -0,0 +1,237 @@
# Feature Specification: Ticket Creation, Messages & Attachments
**Feature Branch**: `003-ticketing`
**Created**: 2026-09-02
**Status**: Draft
**Input**: User description: "Phase 5 of docs/10-implementation-roadmap.md: Ticket + Problem
models (kept separate), message types, attachment pipeline (object storage, scanning, expiring
URLs), ticket lifecycle state machine. Per docs/04-ticketing-and-problem-management.md and
docs/06-database-schema.md."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - A trusted request creates a durable ticket immediately (Priority: P1)
The moment a validated inbound request (from the SaaS integration trust boundary) describes a
customer's problem, SupportHub creates a durable ticket right away — before any diagnosis,
before any human is involved. The ticket starts in a `NEW` status. If the same underlying problem
recurs for the same product/tenant, the new ticket is linked to the existing `Problem` record
rather than creating a duplicate; if it's a genuinely new problem, a new `Problem` record is
created alongside the ticket. A retried request (same idempotency key) returns the
already-created ticket instead of creating a second one.
**Why this priority**: This is the foundational principle of the whole product ("ticket created
at the start of the journey, not after AI gives up") and everything else in this feature — and
every future feature (AI, orchestration, resolution) — depends on the ticket/problem records
existing first.
**Independent Test**: Send a valid inbound request through the trust boundary and confirm a
`Ticket` (status `NEW`) and a `Problem` exist immediately, correctly linked to the validated
product/tenant/user context; send the same request again with the same idempotency key and
confirm no second ticket is created.
**Acceptance Scenarios**:
1. **Given** a validated inbound request describing a problem, **When** it's processed, **Then**
a `Ticket` is created in `NEW` status and a `Problem` is created (or an existing one reused —
see Scenario 3), both linked to the validated product/tenant/user context, before any further
processing occurs.
2. **Given** a ticket was just created, **When** its record is inspected, **Then** it has a
human-referenceable code (e.g. `DQB-2026-00567`-style), the originating product, and the
trusted customer reference — never a raw, unvalidated value from the request.
3. **Given** a customer reports what is recognizably the same underlying problem again (same
product, same recognizable symptoms/context) as an existing open `Problem`, **When** a new
ticket is created for it, **Then** the new ticket links to the *existing* `Problem` record
rather than creating a duplicate one.
4. **Given** an inbound request carries an idempotency key that was already used for a
successfully created ticket, **When** the request is retried, **Then** the existing ticket is
returned and no second ticket or problem is created.
---
### User Story 2 - Ticket messages are typed, and internal notes are never visible to customers (Priority: P2)
A ticket accumulates a timeline of messages — from the customer, from AI (in future), from
agents, from the system, and internal-only notes (investigation/solution notes, general internal
notes). Every message has a type, and the API enforces — not just the UI — that
customer-invisible message types can never reach a customer-scoped read.
**Why this priority**: Without a message timeline there's no record of the interaction to show
anyone; without enforced internal-note privacy, an agent's private note becomes a customer-facing
leak the moment someone builds a UI that forgets to filter client-side.
**Independent Test**: Post one of each message type on a ticket, then read the ticket's messages
as a customer-scoped caller and confirm only customer-visible types appear; read the same
messages as an agent-scoped caller and confirm all types appear.
**Acceptance Scenarios**:
1. **Given** a ticket, **When** a message of any defined type is posted to it, **Then** it's
stored with its type, author reference, body, and a customer-visibility flag derived from its
type (never independently settable per-message in a way that contradicts the type).
2. **Given** a ticket has both customer-visible and internal-only messages, **When** its messages
are read through a customer-scoped endpoint, **Then** only customer-visible messages are
returned — internal notes are absent from the response entirely, not merely hidden by a flag.
3. **Given** the same ticket, **When** its messages are read through an agent-scoped endpoint,
**Then** every message, including internal notes, is returned.
4. **Given** a caller not authorized for a given ticket's tenant, **When** they attempt to read or
post a message on it, **Then** the request is rejected regardless of message type.
---
### User Story 3 - Attachments are safely stored and only ever downloaded through expiring, authorized URLs (Priority: P3)
A customer or agent can attach a file (screenshot, PDF, log, video, document) to a ticket. The
file goes to object storage, never to PostgreSQL — only its metadata and a storage reference are
stored in the database. It is not available for download until it has cleared a malware scan.
Every download happens through a short-lived, authorization-checked URL scoped to that ticket's
tenant/user context — never a permanent or unauthenticated link.
**Why this priority**: Attachments are common (screenshots, logs) but not required for the
minimum ticket flow to work, and getting the security properties right (never in Postgres, never
downloadable pre-scan, never a permanent link) matters more than shipping it first.
**Independent Test**: Upload a file to a ticket and confirm it's rejected for download until scan
status clears; confirm a generated download URL stops working after it expires; confirm a caller
outside the ticket's tenant cannot generate or use a download URL for it.
**Acceptance Scenarios**:
1. **Given** a file upload to a ticket, **When** it's outside the configured type/size limits,
**Then** it's rejected before being sent to object storage.
2. **Given** an accepted upload, **When** it has not yet cleared malware scanning, **Then** it is
not downloadable — its status is visibly `pending`, not silently unavailable.
3. **Given** a file that clears scanning, **When** an authorized caller requests to download it,
**Then** they receive a time-limited URL that stops working after it expires.
4. **Given** a file that fails malware scanning, **When** anyone attempts to download it,
**Then** the download is refused and the failure is visible on the attachment's record.
5. **Given** a caller outside the ticket's tenant/user context, **When** they attempt to generate
or use a download URL for one of its attachments, **Then** the request is rejected.
---
### Edge Cases
- What happens when two requests for the same new problem arrive concurrently (not a literal
retried idempotency key, but a genuine race — e.g. a flaky client double-submits without
reusing the idempotency key)? Out of scope to fully solve here beyond the idempotency-key
mechanism in User Story 1 — true duplicate-problem detection beyond exact idempotency-key reuse
is a knowledge/classification concern for a future AI feature, not this one.
- What happens when a ticket's status is updated by two actors at nearly the same time (e.g. a
customer reopens while an agent is closing)? The update that observes a stale status MUST be
rejected and retried against the current state — not silently overwrite the other actor's
change (Constitution Principle VII).
- What happens when an attachment upload is interrupted mid-transfer? The attachment record MUST
NOT be considered available; a resumed/retried upload is a new attempt, not a partial record
left in a downloadable-looking state.
- What happens when a malware scan itself fails to run (infrastructure error, not "found
malware")? The attachment MUST remain `pending`, never silently promoted to available.
- What happens when a message is posted with a type that doesn't exist in the defined set? The
request MUST be rejected — message type is not free text.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST create a `Ticket` record immediately upon processing a validated
inbound request describing a problem — before any diagnosis, classification, or human
involvement occurs.
- **FR-002**: The system MUST create or reuse a `Problem` record for every ticket: a new `Problem`
when none matches, or the existing `Problem` when the ticket represents a recurrence of one
already open for the same product/tenant context.
- **FR-003**: `Ticket` and `Problem` MUST remain separate, related entities — a ticket references
exactly one problem; a problem may have many tickets (Constitution Principle VIII).
- **FR-004**: The system MUST honor the inbound request's idempotency key: a retried request
carrying a previously-used key returns the already-created ticket rather than creating a new
one.
- **FR-005**: Every ticket MUST have a human-referenceable code, unique, generated by the system
— never supplied by the caller.
- **FR-006**: A ticket's status MUST only ever be one of the defined lifecycle states, and MUST
only transition through valid state changes (an invalid transition is rejected, not silently
coerced).
- **FR-007**: A ticket's status update MUST use optimistic concurrency control: an update based on
a stale prior status is rejected, not applied on top of a change it didn't observe.
- **FR-008**: Every message posted to a ticket MUST have one of the defined message types, and its
customer-visibility MUST be determined by its type, not independently settable in a way that
contradicts the type.
- **FR-009**: The system MUST NOT return customer-invisible message types (internal notes,
investigation notes, solution notes) through any customer-scoped read of a ticket's messages —
enforced at the API/serialization layer, not left to client-side filtering.
- **FR-010**: The system MUST reject reading or posting on a ticket by a caller not authorized for
that ticket's tenant/user context, regardless of message type or attachment involved.
- **FR-011**: Attachment files MUST be stored in object storage, never in PostgreSQL — the
database stores only metadata and a storage reference.
- **FR-012**: The system MUST validate an attachment's type and size against configured limits
before accepting the upload.
- **FR-013**: An uploaded attachment MUST NOT be downloadable until it has cleared malware
scanning; its scan status MUST be visible on its record (`pending` / `clean` / `infected` /
`rejected`).
- **FR-014**: Attachment downloads MUST only be possible through a time-limited,
authorization-checked URL scoped to the ticket's tenant/user context — never a permanent or
unauthenticated link.
- **FR-015**: The system MUST scope every ticket, message, and attachment query by the caller's
validated tenant/user context — a caller-supplied identifier alone is never sufficient
authorization (Constitution Principle I).
### Key Entities
- **Ticket**: The durable, operational record of one support interaction — status, priority,
severity, the product/tenant/customer it belongs to, and links to its problem, messages,
attachments, and (in later features) AI sessions, assignments, and escalation events.
- **Problem**: The underlying issue being solved, which can outlive and span multiple tickets —
statement, symptoms, impact, severity, environment. Deliberately separate from `Ticket`.
- **Ticket Message**: One entry in a ticket's timeline — typed (`CUSTOMER_MESSAGE`, `AI_MESSAGE`,
`AGENT_MESSAGE`, `INTERNAL_NOTE`, `SYSTEM_EVENT`, `INVESTIGATION_NOTE`, `SOLUTION_NOTE`), with
an author reference, body, and a visibility derived from its type.
- **Ticket Attachment**: Metadata for one uploaded file — storage reference (never the file
itself), original filename, MIME type, size, scan status, and who uploaded it.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of validated inbound requests result in a ticket existing before any further
processing — zero requests that pass the trust boundary without a corresponding ticket.
- **SC-002**: A retried request using the same idempotency key never produces more than one
ticket, regardless of how many times it's retried.
- **SC-003**: Zero internal-only messages ever appear in a customer-scoped read of a ticket's
timeline, verified across every defined message type.
- **SC-004**: Zero attachment files are ever persisted directly in the database — 100% go to
object storage with only a reference stored.
- **SC-005**: An attachment that hasn't cleared malware scanning is rejected for download 100% of
the time it's attempted.
- **SC-006**: A generated attachment download URL becomes unusable after its configured expiry —
verified by attempting to use it past that point.
- **SC-007**: Two concurrent status updates to the same ticket never both apply silently — exactly
one succeeds against the state it observed, and the other is rejected and must retry.
## Assumptions
- This feature covers ticket/problem creation, the message timeline, and the attachment pipeline
only. It does NOT include: AI diagnosis/classification (Phase 4), investigation/root
cause/solution/resolution workflows (Phase 9), orchestration/assignment/SLA (Phases 6-8), or the
customer-confirmation/auto-close/reopen *workflow* automation (Phase 9) — though the `REOPENED`
status itself is part of the lifecycle state machine this feature defines, since doc 04 lists it
as a core ticket status.
- "Recognizably the same underlying problem" (FR-002/User Story 1 Scenario 3) is intentionally
left without a precise matching algorithm here — real recurring-problem detection is a
knowledge/classification capability that belongs to the AI-support feature (Phase 4). For this
feature, an explicit, caller-supplied reference (e.g. a prior ticket/problem id in the inbound
request's `referenceIds`, per docs/02 §3) is sufficient grounds to link to an existing `Problem`
— this feature does not attempt fuzzy/semantic matching on its own.
- AI-driven status transitions (`AI_ANALYZING`, `AI_TROUBLESHOOTING`, `AI_VERIFYING`,
`AI_RESOLVED`) are part of the lifecycle state machine's defined states (FR-006), but nothing in
this feature *automatically drives* a ticket into them — that requires the AI-support feature
(Phase 4), which doesn't exist yet. This feature only guarantees the state machine itself is
correct and that transitions can be triggered (e.g. by an authorized caller or a future feature)
without corrupting ticket state under concurrency.
- Row-level security (Postgres RLS) as a defense-in-depth layer under the application-level tenant
scoping in FR-015 (per `docs/11-architect-additions-gaps-and-recommendations.md` §A3) is a
valuable hardening step but is deliberately deferred — `REQUIRES BUSINESS/PLATFORM
CONFIRMATION` on whether/when to adopt it, not invented here. Application-level scoping (FR-015)
is the enforced control for this feature.
- Malware scanning integration specifics (which scanner/service) are a technical decision left to
planning — this spec only requires that the scan gate and its visible states exist.
+264
View File
@@ -0,0 +1,264 @@
---
description: "Task list for 003-ticketing"
---
# Tasks: Ticket Creation, Messages & Attachments
**Input**: Design documents from `specs/003-ticketing/`
**Prerequisites**: [plan.md](./plan.md), [spec.md](./spec.md), [research.md](./research.md),
[data-model.md](./data-model.md), [contracts/ticket-lifecycle-contract.md](./contracts/ticket-lifecycle-contract.md),
[quickstart.md](./quickstart.md)
**Tests**: Included as first-class tasks (state machine, idempotency, and visibility enforcement
are exactly the kind of "MUST" requirements regressions silently break), same approach as
002-saas-integration.
**Organization**: Tasks are grouped by user story (US1 = P1 ticket/problem creation, US2 = P2
messages, US3 = P3 attachments).
## Format: `[ID] [P?] [Story] Description`
All file paths are relative to `supporthub-api/` (repo root).
---
## Phase 1: Setup
- [X] T001 Add a `minio` service to `docker-compose.test.yml` and
`docker-compose.development.yml` (image `minio/minio`, console + API ports), and set
`AWS_S3_ENDPOINT`/`AWS_S3_BUCKET`/`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY` in
`.env.test`/`.env.development` to point at it (research.md "local/test object storage")
- [X] T002 [P] Add `getPresignedUploadUrl(objectName, contentType, expirySeconds?)` to
`src/infrastructure/storage/storage.service.ts`, mirroring the existing
`getPresignedUrl`/`PutObjectCommand` pattern already used by `uploadFile`
**Checkpoint**: Object storage is reachable locally/in CI; the service can mint both upload and
download presigned URLs.
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Schema and shared primitives (state machine, visibility map, scanner interface)
every user story depends on.
**⚠️ CRITICAL**: No user-story stage work can begin until this phase is complete.
- [X] T003 Add `Ticket`, `Problem`, `TicketMessage`, `TicketAttachment` models to
`prisma/schema.prisma` per `data-model.md` (including `Ticket.version`,
`Ticket.idempotencyKey`, `Ticket.customerId` FK to the existing `CustomerReference`,
`Ticket.categoryId` FK to the existing `Category`)
- [X] T004 Run `npm run prisma:generate` and create the migration (`npm run prisma:migrate`) for
T003 (depends on T003)
- [X] T005 [P] Define the 12-state adjacency table and a pure `isValidTransition(from, to):
boolean` function in `src/modules/ticketing/tickets/mapper/ticket-state-machine.ts` per
research.md's exact edge list
- [X] T006 [P] Define the message type→visibility constant map and a pure
`isVisibleToCustomer(type): boolean` function in
`src/modules/ticketing/messages/mapper/message-visibility.ts` per research.md
- [X] T007 [P] Define the `MalwareScanner` interface and the fail-closed
`UnimplementedPlaceholderScanner` in
`src/modules/ticketing/attachments/mapper/malware-scanner.ts` per research.md — logs a
loud warning on every call
- [X] T008 [P] Add a ticket-code generator (`generateTicketCode(externalProductId, sequence):
string`) in `src/modules/ticketing/tickets/mapper/ticket-code.ts` per research.md's format
**Checkpoint**: Schema migrated; state machine, visibility map, scanner interface, and code
generator exist and are independently unit-testable. User stories can now be built.
---
## Phase 3: User Story 1 - A trusted request creates a durable ticket immediately (Priority: P1) 🎯 MVP
**Goal**: `POST /v1/support/requests` (002-saas-integration) creates a real `Ticket`+`Problem`
instead of echoing context back; ticket status transitions are validated and concurrency-safe.
**Independent Test**: Quickstart Scenarios 1, 2, 3, 6.
### Tests for User Story 1
- [X] T009 [P] [US1] Unit tests for `ticket-state-machine.ts` (every valid edge accepted, a
sample of invalid edges rejected) in `tests/unit/ticketing/ticket-state-machine.test.ts`
- [X] T010 [P] [US1] Unit tests for `ticket-code.ts` (format, per-product-per-year sequencing) in
`tests/unit/ticketing/ticket-code.test.ts`
- [X] T011 [US1] Integration test covering Quickstart Scenarios 1, 2, 3, 6 (creation, idempotent
retry, recurring-problem linking via `referenceIds`, concurrent status-update rejection)
against a real Postgres in `tests/integration/ticket-creation.test.ts`
### Implementation for User Story 1
- [X] T012 [US1] Add `ProblemsRepository` (create; find-by-reference using an explicit prior
ticket/problem id — research.md's explicit-reference-only rule) in
`src/modules/ticketing/tickets/repository/problems.repository.ts` (depends on T004)
- [X] T013 [US1] Add `TicketsRepository` (atomic `create` with idempotency-key upsert per
data-model.md's `@@unique([productId, idempotencyKey])`; `findById`; `findByCode`;
`updateStatus` using the `version`-based optimistic-concurrency `UPDATE ... WHERE version =
?` from research.md) in `src/modules/ticketing/tickets/repository/tickets.repository.ts`,
replacing the old placeholder `findAllProducts`-style stub (depends on T004)
- [X] T014 [US1] Add `TicketMessagesRepository.create` (used internally for the `SYSTEM_EVENT`
creation/transition record — full messages CRUD is User Story 2) in
`src/modules/ticketing/messages/repository/messages.repository.ts` (depends on T004, T006)
- [X] T015 [US1] Add `TicketsService.createFromInboundRequest(reqContext, body)`: resolves/creates
the `Problem` (T012), creates/fetches the `Ticket` (T013), writes the creation
`SYSTEM_EVENT` message (T014) — all synchronous within one request (FR-001) — in
`src/modules/ticketing/tickets/service/tickets.service.ts` (depends on T012, T013, T014)
- [X] T016 [US1] Add `TicketsService.updateStatus(ticketId, newStatus, expectedVersion, actor)`:
validates the transition via `isValidTransition` (T005), calls the repository's optimistic
update, writes a `SYSTEM_EVENT` message on success, throws `409 CONFLICT` on version
mismatch and `400 INVALID_TRANSITION` on an invalid edge (depends on T005, T013, T014)
- [X] T017 [US1] Replace `src/modules/catalog/products/routes/inbound-request.routes.ts`'s stub
handler: call `TicketsService.createFromInboundRequest` and respond with
`{ ticketId, code, status, problemId }` instead of echoing `reqContext` back (depends on
T015)
- [X] T018 [US1] Add `PATCH /tickets/:ticketId/status` (body `{ status, expectedVersion }`) and
`GET /tickets/:ticketId` routes, tenant-scoped per FR-015, in
`src/modules/ticketing/tickets/routes/tickets.routes.ts`, replacing the old placeholder
`GET /tickets` list stub; register the module's routes from `src/api/routes.ts` (depends on
T016)
- [X] T019 [US1] Run Quickstart Scenarios 1, 2, 3, 6 locally and confirm all four pass
**Checkpoint**: User Story 1 is fully functional — every trusted inbound request produces a real,
concurrency-safe, idempotent ticket. This is a deployable/demoable increment even before
messages/attachments exist.
---
## Phase 4: User Story 2 - Ticket messages are typed, and internal notes are never visible to customers (Priority: P2)
**Goal**: Full message CRUD with visibility enforced at the query layer.
**Independent Test**: Quickstart Scenario 4.
### Tests for User Story 2
- [X] T020 [P] [US2] Unit tests for `message-visibility.ts` (every type maps correctly, including
that the mapping can't be overridden by caller input at the type level) in
`tests/unit/ticketing/message-visibility.test.ts`
- [X] T021 [US2] Integration test covering Quickstart Scenario 4 (post one of each type; confirm
customer-scoped read excludes internal types entirely; confirm agent-scoped read includes
all) against a real Postgres in `tests/integration/ticket-messages.test.ts`
### Implementation for User Story 2
- [X] T022 [US2] Extend `TicketMessagesRepository` with `findVisibleToCustomer(ticketId)`
(`WHERE visibleToCustomer = true`, per data-model.md's index) and `findAll(ticketId)`
(agent-scope) — both tenant-scoped per FR-015 (depends on T014)
- [X] T023 [US2] Add `MessagesService.post(ticketId, actor, type, body)` (sets
`visibleToCustomer` from `isVisibleToCustomer(type)` — never from request input, FR-008)
and `.listForCustomer`/`.listForAgent` in
`src/modules/ticketing/messages/service/messages.service.ts` (depends on T006, T022)
- [X] T024 [US2] Add routes in `src/modules/ticketing/messages/routes/messages.routes.ts`:
`POST /tickets/:ticketId/messages` (customer/agent both post, gated by
`fastify.authenticate`), `GET /tickets/:ticketId/messages` (customer-scoped),
`GET /agent/tickets/:ticketId/messages` (agent-scoped) — register from `src/api/routes.ts`
(depends on T023)
- [X] T025 [US2] Run Quickstart Scenario 4 locally and confirm it passes
**Checkpoint**: Both User Story 1 and 2 work together — a created ticket now has a real,
correctly-scoped message timeline.
---
## Phase 5: User Story 3 - Attachments are safely stored and only downloadable through expiring, authorized URLs (Priority: P3)
**Goal**: Presigned-PUT upload → confirm → async scan → scan-gated presigned-GET download.
**Independent Test**: Quickstart Scenario 5.
### Tests for User Story 3
- [X] T026 [P] [US3] Unit tests for `UnimplementedPlaceholderScanner` (always resolves
`'infected'`, never throws) in `tests/unit/ticketing/malware-scanner.test.ts`
- [X] T027 [US3] Integration test covering Quickstart Scenario 5 (upload-url → confirm → download
refused while `pending` → download refused after the placeholder scanner marks
`infected`) against a real Postgres/Redis/MinIO in
`tests/integration/ticket-attachments.test.ts`
### Implementation for User Story 3
- [X] T028 [US3] Add `AttachmentsRepository` (create with `scanStatus: 'pending'`; findById;
`updateScanStatus`) in
`src/modules/ticketing/attachments/repository/attachments.repository.ts` (depends on T004)
- [X] T029 [US3] Add `AttachmentsService.requestUploadUrl(ticketId, fileName, mimeType,
sizeBytes)`: validates type/size against configured limits (FR-012) before calling
`storageService.getPresignedUploadUrl` (T002) — depends on T002
- [X] T030 [US3] Add `AttachmentsService.confirmUpload(ticketId, storageKey, fileName, mimeType,
sizeBytes, uploadedBy)`: creates the `TicketAttachment` row (T028) and enqueues a job on
`QueueName.ATTACHMENTS` via the existing `queueManager` (depends on T028)
- [X] T031 [US3] Add `AttachmentsService.requestDownloadUrl(ticketId, attachmentId)`: returns
`storageService.getPresignedUrl` only when `scanStatus === 'clean'`, else throws `409` with
the current status (FR-013/FR-014) — depends on T028
- [X] T032 [US3] Replace the log-only stub in `src/jobs/attachments/index.ts`: call the bound
`MalwareScanner` (T007), then `AttachmentsRepository.updateScanStatus` with the result —
depends on T007, T028
- [X] T033 [US3] Wire `registerAttachmentWorker()` into `src/bootstrap/queue.bootstrap.ts` (it's
currently defined but never called anywhere) — depends on T032
- [X] T034 [US3] Add routes in
`src/modules/ticketing/attachments/routes/attachments.routes.ts`:
`POST /tickets/:ticketId/attachments/upload-url`,
`POST /tickets/:ticketId/attachments/:attachmentId/confirm`,
`GET /tickets/:ticketId/attachments/:attachmentId/download-url` — register from
`src/api/routes.ts` (depends on T029, T030, T031)
- [X] T035 [US3] Run Quickstart Scenario 5 locally and confirm it passes
**Checkpoint**: All three user stories work independently and together — a ticket now has
creation, a message timeline, and a securely-gated attachment pipeline.
---
## Phase 6: Polish & Cross-Cutting Concerns
- [X] T036 [P] Add a "Ticketing" section to `README.md` describing the inbound-to-ticket flow,
the status-transition contract, and the attachment pipeline (including that downloads are
permanently blocked until a real `MalwareScanner` replaces the placeholder)
- [X] T037 [P] Update `specs/003-ticketing/checklists/requirements.md` Notes with any
implementation-time findings (e.g. concurrency edge cases discovered while testing T011)
- [X] T038 Run `npx tsx scripts/check-architecture.ts` and `npm run lint`/`npm run typecheck` to
confirm the new modules respect existing module-boundary rules
- [X] T039 Full regression: `npm run test:unit` (scoped to `tests/unit`, per 001/002's fix) to
confirm nothing broke elsewhere
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies
- **Foundational (Phase 2)**: Depends on Setup — BLOCKS all user stories
- **User Story 1 (Phase 3)**: Depends on Foundational — no dependency on US2/US3
- **User Story 2 (Phase 4)**: Depends on Foundational + a `Ticket` existing to attach messages to
— practically sequenced after US1, though its own repository/service/routes are independent
code
- **User Story 3 (Phase 5)**: Depends on Foundational + a `Ticket` existing — independent of US2
- **Polish (Phase 6)**: Depends on all three user stories
### Parallel Opportunities
- T001/T002 (Setup)
- T005/T006/T007/T008 (independent Foundational primitives)
- T009/T010 (independent unit test files)
- T020 alongside US1's later tasks once T006 exists
- T026 alongside US1/US2's later tasks once T007 exists
- T036/T037 in Polish
---
## Implementation Strategy
### MVP First (User Story 1 Only)
1. Setup + Foundational (T001-T008)
2. User Story 1 (T009-T019)
3. **STOP and VALIDATE**: Quickstart Scenarios 1, 2, 3, 6 pass — every trusted request now
produces a real, durable, idempotent, concurrency-safe ticket. This alone is a meaningful
product milestone even before messages/attachments exist.
### Incremental Delivery
1. Setup + Foundational → schema migrated, primitives tested
2. Add User Story 1 → tickets are real (MVP)
3. Add User Story 2 → tickets have a correctly-scoped conversation timeline
4. Add User Story 3 → tickets support secure attachments
5. Polish → docs and full regression
@@ -0,0 +1,62 @@
# Specification Quality Checklist: Product Knowledge Management & Retrieval
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-09-02
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Scope is deliberately Phase 3 only (per docs/10-implementation-roadmap.md): knowledge/known-
issue/error-code/runbook models, admin CRUD, versioning/publish state, and a filtered
(non-semantic) retrieval layer. AI diagnosis, runbook execution, and tool systems are Phase 4
— explicitly out of scope, see Assumptions.
- Full semantic/vector retrieval is deliberately deferred (doc 11 §B1) — this feature's retrieval
is real and usable (structured filtering + validation-status ranking), not a stand-in.
- All items pass; no revision iterations were needed.
## Implementation notes (added during /speckit-implement)
- **Found and fixed a real bug before it reached tests**: the `GET /knowledge/retrieve`
controller initially queried `KnowledgeEntry.productId` using the raw query-string value
directly, but every other endpoint in this feature (and every prior feature) treats
`:externalProductId` as the external SaaS product id, resolved to the internal `Product.id`
before touching the database. Retrieval would have silently returned zero results for every
real caller (external id never matches an internal cuid). Fixed by adding a lenient
`tryResolveProductId` variant (returns `null` instead of a `404`, since an unregistered product
queried for retrieval is correctly "no matches," not an error — contract guarantee 5) alongside
the existing strict `resolveProductId` used by the admin routes.
- No dedicated unit-test task (originally T004/T019 in tasks.md) was implemented as a
mock-repository test: unlike 003-ticketing's state machine or message-visibility map, this
feature has no meaningful pure-logic surface — publish/version/retrieve are thin Prisma
queries, not extractable pure functions. Coverage instead comes entirely from integration tests
against a real Postgres (`tests/integration/knowledge-entries.test.ts`,
`known-issues.test.ts`, `runbooks.test.ts`, `knowledge-retrieval.test.ts` — 12 tests, all
verified passing against a live database), which is where this feature's actual risk (version-
history integrity, concurrency, cross-product isolation) lives anyway.
- All 13 integration test files in the repository (36 tests total, spanning this feature and
every prior one) were run together and passed, confirming no regression.
@@ -0,0 +1,57 @@
# Contract: Knowledge Admin CRUD & Retrieval
All admin routes gated by `fastify.authenticate` (research.md — known limitation inherited from
002/003).
## Knowledge Entries
- `POST /admin/products/:externalProductId/knowledge` — creates a new entry, `version: 1`,
`isCurrentVersion: true`, `status: draft`.
- `PATCH /admin/knowledge/:code/publish` — body `{ effectiveDate? }` — sets `status: published`.
- `PATCH /admin/knowledge/:code/unpublish` — sets `status: unpublished`.
- `PATCH /admin/knowledge/:code/validate` — body `{ validationStatus }` — sets validation status
on the current version.
- `PUT /admin/knowledge/:code` — body is the new content + `expectedVersion`. Creates a new
version per research.md's conditional-update-then-insert; `409 CONFLICT` on a stale
`expectedVersion`.
- `GET /admin/knowledge/:code/versions` — lists every version of this entry, newest first,
including non-current ones (admin-only history view).
## Runbooks
Same shape as Knowledge Entries, keyed by `(key, productId)` instead of `code`:
- `POST /admin/products/:externalProductId/runbooks`
- `PUT /admin/products/:externalProductId/runbooks/:key` (versioned edit, same
`expectedVersion`/`409` rule)
- `PATCH /admin/products/:externalProductId/runbooks/:key/deactivate`
- `GET /admin/products/:externalProductId/runbooks/:key` — current version only (execution-ready
lookup, not the admin history view)
## Error Codes & Known Issues
- `POST /admin/products/:externalProductId/error-codes`
- `POST /admin/products/:externalProductId/known-issues` — body includes `errorCodeId` (optional)
- `GET /admin/products/:externalProductId/known-issues/by-error-code/:code` — FR-007's direct
lookup
## Retrieval
- `GET /knowledge/retrieve?productId=&feature=&category=` — the filtered, ranked query
(research.md). Returns only `published`, currently-effective, current-version entries scoped to
the given product, validated entries ranked first. Empty array on no matches, never an error.
## Guarantees (callable contract)
1. **A draft entry is never returned by `/knowledge/retrieve`**, regardless of any other filter
(SC-001).
2. **Retrieval never crosses product scope** — a query for product A never returns product B's
entries, even if B has a matching `code`/`feature` (SC-002).
3. **Publishing takes effect within the same request cycle** — no cache/propagation delay before
a newly-published entry appears in retrieval (SC-003).
4. **Editing never destroys a prior version**`GET .../versions` after an edit still includes
the pre-edit content (SC-004).
5. **A stale-version edit is rejected with `409`, never silently applied on top of a change it
didn't observe** — same guarantee class as 003-ticketing's ticket-status concurrency.
6. **A validated entry outranks an equally-matching unvalidated one** in every retrieval result
that includes both (SC-005).
+80
View File
@@ -0,0 +1,80 @@
# Phase 1 Data Model: Product Knowledge Management & Retrieval
All new models use `cuid()` ids. Refines `docs/06-database-schema.md`'s conceptual
`KnowledgeEntry`/`Runbook` shapes with an explicit version-history mechanism (research.md).
## KnowledgeEntry
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| code | String | `KB-<PRODUCT>-<SEQ>` style, e.g. `KB-DQ-102` — logical identifier shared across versions |
| version | Int @default(1) | |
| isCurrentVersion | Boolean @default(true) | Exactly one `true` row per `code` at a time (research.md) |
| productId | String | FK → `Product` |
| feature | String? | |
| type | String | `known_issue` \| `faq` \| `resolution_procedure` \| `operations` |
| problem | String? | |
| symptoms | String? | |
| errorCode | String? | Free-text reference for display; structured linkage is via `ErrorCode`/`KnownIssue` separately |
| cause | String? | |
| recommendedSolution | String? | |
| verificationSteps | String? | |
| escalationGuidance | String? | |
| status | String @default("draft") | `draft` \| `published` \| `unpublished` |
| effectiveDate | DateTime? | Null = effective immediately once published |
| categoryScope | String[] | |
| validationStatus | String @default("unvalidated") | `unvalidated` \| `validated` |
| owner | String? | |
| lastReview | DateTime? | |
| source | String? | |
| createdAt | DateTime @default(now()) | |
**Constraints**: `@@unique([code, version])`. Index on `(productId, isCurrentVersion, status,
effectiveDate)` — the exact shape retrieval queries on.
**Retrieval eligibility rule** (not a DB constraint, enforced in the repository's query):
`isCurrentVersion = true AND status = 'published' AND (effectiveDate IS NULL OR effectiveDate <=
now())`.
## ErrorCode
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| code | String | e.g. `LAYOUT_PARSE_042` |
| productId | String | FK → `Product` |
| description | String | |
**Constraints**: `@@unique([productId, code])` — unique within a product, matching FR-006.
## KnownIssue
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| productId | String | FK → `Product` |
| errorCodeId | String? | FK → `ErrorCode` |
| description | String | |
| status | String @default("open") | |
## Runbook
| Field | Type | Notes |
|---|---|---|
| id | String @id @default(cuid()) | |
| key | String | Logical identifier shared across versions, e.g. `PDF_HTML_CONVERSION_FAILURE` |
| version | Int @default(1) | |
| isCurrentVersion | Boolean @default(true) | Same version-history mechanism as `KnowledgeEntry` |
| productId | String | FK → `Product` |
| steps | Json | Ordered array — order preserved exactly as authored (FR-008) |
| active | Boolean @default(true) | |
**Constraints**: `@@unique([key, productId, version])`. Lookup-by-key queries filter
`isCurrentVersion = true AND active = true`.
## Product (relations added by this feature)
`knowledgeEntries KnowledgeEntry[]`, `errorCodes ErrorCode[]`, `knownIssues KnownIssue[]`,
`runbooks Runbook[]` — the forward relations doc 06 already specified on `Product` but that
couldn't be added until these models existed.
+120
View File
@@ -0,0 +1,120 @@
# Implementation Plan: Product Knowledge Management & Retrieval
**Branch**: `004-product-knowledge` | **Date**: 2026-09-02 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `specs/004-product-knowledge/spec.md`
## Summary
Add the `KnowledgeEntry`/`ErrorCode`/`KnownIssue`/`Runbook` domain: admin CRUD with a
draft/published/unpublished lifecycle and version-on-edit for knowledge entries and runbooks,
structured lookup for error codes/known issues, and a filtered (non-semantic) retrieval query.
This is the first feature to populate `src/modules/ai-support/` — doc 07 places `knowledge`
inside the `ai-support` module group, which doesn't exist in the codebase yet; this feature
creates it with just the `knowledge` submodule, leaving the rest of that group (agents, sessions,
diagnosis, tools, etc.) for the future AI-support feature (Phase 4).
## Technical Context
**Language/Version**: TypeScript 5.4 / Node.js 20+.
**Primary Dependencies**: Prisma (new models), Zod. No new runtime dependency — retrieval is
implemented as filtered Prisma queries (research.md), not a vector-search library.
**Storage**: PostgreSQL via Prisma (new `KnowledgeEntry`, `ErrorCode`, `KnownIssue`, `Runbook`
models per `docs/06-database-schema.md`).
**Testing**: Vitest — unit tests for the version-on-edit logic and retrieval filter/ranking
logic; integration tests for the full admin CRUD + retrieval flow against a real Postgres.
**Target Platform**: Same Fastify modular monolith. New module:
`src/modules/ai-support/knowledge/` (standard module shape per doc 07 — no existing scaffold to
extend, unlike prior features).
**Project Type**: Backend service — single project.
**Performance Goals**: Not performance-sensitive at this phase (no semantic search, no LLM calls)
— a retrieval query is a straightforward filtered/indexed Postgres query.
**Constraints**: MUST NOT return draft/unpublished/not-yet-effective entries from retrieval
(FR-012); MUST preserve prior versions on edit, never overwrite in place (FR-004/FR-009); MUST
scope retrieval to the requested product, never leak cross-product (FR-011).
**Scale/Scope**: Admin CRUD endpoints for all four entity types, one retrieval query endpoint.
Explicitly excludes: semantic/vector retrieval, runbook execution, AI diagnosis, tool systems
(all Phase 4) — 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 | Not directly implicated — knowledge is SupportHub-owned content, not SaaS identity data. `owner`/`lastReview` are free-text admin-set fields, not references into SaaS identity. | PASS — N/A |
| II. Configuration Over Hardcoding | Publish/validation lifecycle, versioning, and retrieval filters are all data-driven (status/effectiveDate/validationStatus columns), not hardcoded conditionals. | PASS |
| III. Layered Architecture With Enforced Module Boundaries | New `ai-support/knowledge` module follows the standard controller/service/repository/routes/schema/mapper/types/constants shape and exposes only its `index.ts` — same convention as every prior module in this codebase. | PASS |
| IV. AI Recommends, Deterministic Policy Decides | Not applicable — no AI reasoning in this feature; retrieval is deterministic filtering, not model inference. | PASS — N/A |
| V. Evidence-Based Verification | Not applicable — no resolution/verification concept in this feature. | PASS — N/A |
| VI. Durable Audit & History | Publish/unpublish and version-on-edit are themselves the durable history mechanism (FR-004/FR-009's "prior versions remain retrievable") — no separate audit log needed for this feature's own concern, though admin actions could optionally also write `AuditLog` rows (see research.md). | PASS |
| VII. Concurrency-Safe, Durable Job Handling | Version-on-edit uses the same optimistic-concurrency-adjacent pattern as 003-ticketing where two admins could race to edit the same entry — see research.md. 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. The version-history refinement (research.md)
is worth calling out against Principle VI explicitly: it turns "prior versions remain
retrievable" from an aspiration into a mechanical guarantee (a query, not a promise), which is
exactly what durable audit/history is supposed to mean in this codebase.
## Project Structure
### Documentation (this feature)
```text
specs/004-product-knowledge/
├── 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 KnowledgeEntry, ErrorCode,
│ KnownIssue, Runbook models per docs/06
├── src/
│ └── modules/
│ └── ai-support/ # NEW module group (doc 07) — only `knowledge`
│ └── knowledge/ populated in this feature
│ ├── controller/
│ ├── routes/
│ ├── schema/
│ ├── repository/
│ ├── service/
│ ├── types/
│ ├── mapper/
│ ├── constants/
│ └── index.ts
└── tests/
├── unit/knowledge/ # version-on-edit, retrieval filter/ranking logic
└── integration/ # admin CRUD + retrieval end to end
```
**Structure Decision**: Single project. New top-level module group `ai-support/` is created for
the first time (doc 07 places `knowledge` there), but only its `knowledge` submodule is built —
`agents/sessions/diagnosis/troubleshooting/tools/tool-execution/verification/escalation` are left
for the Phase 4 feature that actually needs them, matching this codebase's established pattern of
building only what the current phase requires (e.g. `identity/auth`, `orchestration/*` remain
untouched stubs from the original scaffold).
## Complexity Tracking
*No constitution violations — table intentionally omitted.*
+51
View File
@@ -0,0 +1,51 @@
# Quickstart: Validating Product Knowledge Management & Retrieval
Prerequisites: a registered `Product` (from 002-saas-integration's admin endpoints or seeded
directly), migrations applied.
## Scenario 1 — draft is invisible, publish makes it retrievable (User Story 1)
1. Create a knowledge entry for a product. **Expected**: `status: draft`.
2. Query `/knowledge/retrieve?productId=...`. **Expected**: entry absent.
3. Publish it. **Expected**: `status: published`.
4. Query retrieval again. **Expected**: entry present.
## Scenario 2 — editing preserves history (User Story 1)
1. Edit the published entry's content with the correct `expectedVersion`.
2. **Expected**: a new version is created (`version: 2`); `GET .../versions` shows both version 1
(with its original content) and version 2 (current).
3. Query retrieval. **Expected**: only version 2's content appears — version 1 is not retrievable
live, only through the admin history view.
## Scenario 3 — a stale edit is rejected (Edge Cases / concurrency)
1. Read the entry's current `version`.
2. Attempt two edits using the same `expectedVersion`.
3. **Expected**: exactly one succeeds; the other receives `409 CONFLICT`.
## Scenario 4 — known issue resolves by error code (User Story 2)
1. Create an error code (e.g. `LAYOUT_PARSE_042`).
2. Create a known issue referencing it.
3. `GET .../known-issues/by-error-code/LAYOUT_PARSE_042`. **Expected**: the known issue is
returned directly, no search step needed.
## Scenario 5 — a runbook's step order is preserved exactly (User Story 2)
1. Create a runbook with an explicit ordered step list.
2. Look it up by key. **Expected**: steps are returned in the exact authored order.
3. Deactivate it. **Expected**: lookup by key no longer returns it (treated the same as
nonexistent).
## Scenario 6 — retrieval never crosses product scope, and validated ranks first (User Story 3)
1. Seed a published entry for Product A and a published entry for Product B with similar content.
2. Query retrieval scoped to Product A. **Expected**: only Product A's entry appears.
3. Seed two otherwise-equal entries for the same product, one `validated`, one `unvalidated`.
4. Query retrieval. **Expected**: the validated entry appears first in the result order.
## What "done" looks like
All six 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.
+86
View File
@@ -0,0 +1,86 @@
# Phase 0 Research: Product Knowledge Management & Retrieval
## Decision: Versioning mechanism — new row per version, not in-place overwrite
- **Decision**: `docs/06-database-schema.md`'s `KnowledgeEntry`/`Runbook` models are explicitly
"conceptual/pseudo-Prisma... refine field types... during Phase 1 modeling" — their flat
`version Int` field alone doesn't satisfy this feature's FR-004/FR-009 ("prior versions MUST
remain retrievable"), since a plain in-place `UPDATE` overwrites history. This feature refines
the schema: each edit inserts a **new row** sharing the same logical identifier (`code` for
`KnowledgeEntry`, `key`+`productId` for `Runbook`) with `version` incremented, and exactly one
row per logical identifier has `isCurrentVersion: true` at a time. The unique constraint moves
from a bare `code`/`key` to `(code, version)` / `(key, productId, version)`; a partial-unique-
style application check (see next decision) keeps only one current version.
- **Rationale**: This is the standard "immutable version history" pattern and directly satisfies
"prior versions remain retrievable by their own identity" (FR-004) without a separate audit
table — the versions themselves ARE the history, consistent with Constitution Principle VI.
- **Alternatives considered**: A separate `KnowledgeEntryVersion` history table with the main row
only ever holding "current" — rejected as more schema surface for the same guarantee; querying
"give me version 3 of KB-DQ-102" is equally simple either way, and one-table-per-entity-type
keeps retrieval queries (which only ever care about the current version) simpler.
## Decision: Concurrency on edit — conditional update + insert, same class as 003-ticketing
- **Decision**: Creating a new version is two steps inside one transaction: (1)
`UPDATE ... WHERE code = ? AND version = ? AND isCurrentVersion = true SET isCurrentVersion =
false` (the caller's `expectedVersion` must match the current row) — zero rows affected means a
concurrent edit already won, and this edit is rejected with `409 CONFLICT`; (2) only if step 1
affected exactly one row, insert the new current-version row.
- **Rationale**: Directly reuses the optimistic-concurrency pattern already established in
003-ticketing's `Ticket.version` handling — same shape of problem (two admins editing the same
entry), same solution, no new concurrency-control concept introduced into the codebase.
- **Alternatives considered**: Last-write-wins (no `expectedVersion` check) — rejected, would let
one admin's edit silently clobber another's without either of them knowing, which is exactly
what Constitution Principle VII's concurrency requirement exists to prevent.
## Decision: Retrieval — structured filtering, no vector/embedding search
- **Decision**: A retrieval query is `WHERE productId = ? AND isCurrentVersion = true AND status
= 'published' AND (effectiveDate IS NULL OR effectiveDate <= now()) AND (feature filter if
given) AND (categoryScope filter if given)`, ordered by `validationStatus = 'validated'` first,
then by `effectiveDate DESC` (most recently published first) as a simple, defensible tiebreak.
- **Rationale**: Per spec.md's Assumptions and doc 11 §B1, full semantic retrieval is explicitly
a future decision (embedding model, chunking, re-ranking) — this feature's job is a correct,
real, *filtered* retrieval contract that a semantic layer can be added in front of later
without changing what "correct" means (doc 11 §B1's "filters apply before the vector search"
requirement is satisfied by construction, since there's no vector search yet to apply them
before).
- **Alternatives considered**: Postgres full-text search (`tsvector`/`tsquery`) on
problem/symptoms text — considered as a nearer-term relevance improvement, but deferred: it
would still not be "the RAG layer" doc 03 describes, adds index/query complexity beyond what
this phase's requirements (FR-011 through FR-013) actually ask for, and can be added later as
a ranking refinement without a breaking contract change.
## Decision: Known issue lookup by error code
- **Decision**: `KnownIssue.errorCodeId` is a nullable FK to `ErrorCode`; lookup is a direct
`WHERE errorCodeId = ?` query (via the `ErrorCode`'s own id, resolved from its `code` string
first if the caller only has the string).
- **Rationale**: Matches doc 06's shape exactly (`KnownIssue.errorCodeId String?`) and FR-007's
"retrieve a known issue directly by its error code" — a simple indexed FK lookup, no special
design needed.
## Decision: Module placement — new `ai-support/knowledge` module group
- **Decision**: Create `src/modules/ai-support/knowledge/` now, following the standard module
shape (controller/routes/schema/repository/service/types/mapper/constants/index.ts) used by
every other module in this codebase. No other `ai-support` submodule
(agents/sessions/diagnosis/troubleshooting/tools/tool-execution/verification/escalation) is
created — those remain nonexistent until the Phase 4 feature that needs them, matching how
`identity/auth` and most of `orchestration`/`platform` remain untouched placeholder stubs from
the original scaffold rather than being pre-built speculatively.
- **Rationale**: Doc 07 explicitly places `knowledge` inside the `ai-support` group — this is
the documented, correct location, not an open design choice.
- **Alternatives considered**: Placing it under `catalog` (since it's product-scoped content,
similar to `catalog/products`/`catalog/categories`) — rejected; doc 07 already answers this
question, and following it keeps the module layout matching the architecture doc exactly.
## Decision: Admin endpoint authentication
- **Decision**: All admin CRUD endpoints (create/publish/unpublish/version-edit for knowledge
entries and runbooks; create for error codes/known issues) are gated by the existing
`fastify.authenticate` decorator — same known-limitation pattern as 002/003's admin routes (it
doesn't perform real JWT verification yet).
- **Rationale**: Consistency with every other admin surface built so far; introducing a different
auth mechanism just for this feature would be inconsistent without a reason to be.
- **Alternatives considered**: None — this follows established precedent directly.
+218
View File
@@ -0,0 +1,218 @@
# Feature Specification: Product Knowledge Management & Retrieval
**Feature Branch**: `004-product-knowledge`
**Created**: 2026-09-02
**Status**: Draft
**Input**: User description: "Phase 3 of docs/10-implementation-roadmap.md: Knowledge/KnownIssue/
ErrorCode/Runbook models, admin CRUD, versioning + publish state, retrieval (RAG) layer. Per
docs/03-ai-support-architecture.md section 2-3 and docs/06-database-schema.md."
## User Scenarios & Testing *(mandatory)*
### User Story 1 - An admin authors, versions, and publishes knowledge entries (Priority: P1)
An administrator creates a knowledge entry for a product (a known issue, FAQ, resolution
procedure, or operations note), scoped to the right product/feature/category. The entry starts
as a draft, invisible to retrieval. When the admin publishes it, it becomes eligible for
retrieval from its effective date onward. Editing a published entry creates a new version rather
than silently rewriting history, and the admin can mark an entry as validated once it's been
confirmed to actually work.
**Why this priority**: Nothing else in this feature (or the future AI-support feature that
depends on it) has anything to retrieve until knowledge exists, is scoped correctly, and has a
trustworthy draft/published/validated lifecycle — retrieving an unreviewed draft as if it were
trustworthy guidance would be worse than retrieving nothing.
**Independent Test**: Create a draft knowledge entry, confirm it's not retrievable; publish it,
confirm it becomes retrievable from its effective date; edit it, confirm the edit produces a new
version and the prior version remains inspectable.
**Acceptance Scenarios**:
1. **Given** an admin creates a knowledge entry, **When** it's saved without being published,
**Then** it exists with `status: draft` and is never returned by any retrieval query.
2. **Given** a draft entry, **When** an admin publishes it with an effective date, **Then** it
becomes eligible for retrieval starting at that date — not before.
3. **Given** a published entry, **When** an admin edits its content, **Then** the edit is
recorded as a new version (incrementing `version`), and the entry's prior content remains
retrievable by version rather than being overwritten.
4. **Given** a published entry, **When** an admin marks it `validationStatus: validated`,
**Then** that status is visible on every retrieval result that includes it.
5. **Given** a published entry, **When** an admin unpublishes it, **Then** it immediately stops
being returned by retrieval, without being deleted.
---
### User Story 2 - Known issues, error codes, and runbooks are modeled as first-class, product-scoped records (Priority: P2)
Beyond general knowledge entries, an admin can catalog specific known issues (linked to a
structured error code) and author runbooks — ordered, versioned step sequences for a specific
problem type. These are distinct from freeform knowledge entries because they're referenced
structurally (by error code, by runbook key) rather than only found through search.
**Why this priority**: Knowledge entries alone (User Story 1) already deliver standalone value —
this story adds the structured lookup paths (a specific error code, a specific runbook key) doc
03's example ("searches: feature documentation → error catalog → known issues → troubleshooting →
runbooks") depends on, but a knowledge base without them is still useful.
**Independent Test**: Create an error code and a known issue referencing it; look the known issue
up by error code and confirm it resolves; create a runbook with an ordered step sequence for a
product; look it up by its key and confirm the exact step order is preserved.
**Acceptance Scenarios**:
1. **Given** an admin creates an error code for a product, **When** it's saved, **Then** it has a
unique, product-scoped code (e.g. `LAYOUT_PARSE_042`) and a description.
2. **Given** an existing error code, **When** an admin creates a known issue referencing it,
**Then** the known issue can be looked up directly by that error code.
3. **Given** an admin creates a runbook for a product with an ordered list of steps, **When** it's
saved, **Then** the step order is preserved exactly as authored — never reordered or
deduplicated by the system.
4. **Given** an existing runbook, **When** an admin edits its steps, **Then** the edit is recorded
as a new version, matching User Story 1's versioning behavior for knowledge entries.
5. **Given** a runbook, **When** an admin deactivates it, **Then** it's excluded from lookup
without being deleted — the same active/inactive convention as User Story 1's publish state.
---
### User Story 3 - Retrieval returns only relevant, filtered, validation-aware knowledge for a given context (Priority: P3)
Given a product, and optionally a feature/category/problem-type context, a retrieval query
returns only the knowledge entries that are published, past their effective date, and scoped to
that context — never an unfiltered dump of everything in the knowledge base. When both a
validated and an unvalidated entry are otherwise equally relevant, the validated one is
preferred.
**Why this priority**: This is what makes the knowledge base actually usable by a future
caller (the AI-support feature) instead of just an admin content library — but it depends on
User Stories 1 and 2 existing first, and doc 03 itself frames full semantic retrieval as a later
design decision (see Assumptions), so this story delivers the retrieval *contract* now without
requiring a vector/embedding pipeline to exist yet.
**Independent Test**: Seed knowledge entries across two different products, query retrieval
scoped to one product, and confirm only that product's published, effective entries are
returned — never the other product's, never drafts, never entries not yet effective; seed one
validated and one unvalidated entry that are otherwise equally relevant, and confirm the
validated one is ranked first.
**Acceptance Scenarios**:
1. **Given** knowledge entries across multiple products, **When** a retrieval query is scoped to
one product, **Then** only that product's entries are ever returned.
2. **Given** a mix of draft and published entries, **When** a retrieval query runs, **Then**
drafts are never returned, regardless of how well they'd otherwise match.
3. **Given** a published entry whose effective date is in the future, **When** a retrieval query
runs before that date, **Then** the entry is not returned.
4. **Given** a validated and an unvalidated entry that both match a query, **When** results are
returned, **Then** the validated entry is ranked ahead of the unvalidated one.
5. **Given** a retrieval query with no matches, **When** it runs, **Then** it returns an empty
result — never an error, and never a fallback to unrelated knowledge.
---
### Edge Cases
- What happens when an admin tries to publish a knowledge entry with no content (empty
problem/solution fields)? Out of scope for strict validation here — this feature stores what's
given; a content-quality review workflow is not part of this phase.
- What happens when two knowledge entries could both plausibly answer the same query (e.g. a
known issue and a general FAQ)? Both are returned if both match the filters — ranking beyond
validation-status preference (User Story 3 Scenario 4) is explicitly not solved here; true
relevance ranking is the future semantic-retrieval work in Assumptions.
- What happens when a runbook is looked up by a key that doesn't exist, or exists but is
inactive? It's treated as not found either way — an inactive runbook is not distinguishable
from a nonexistent one to a retrieval caller, only to an admin managing it directly.
- What happens when an error code is deleted while a known issue still references it? Out of
scope — this feature doesn't implement deletion of error codes that have active references;
only unpublish/deactivate operations are defined (Scenarios above), matching the rest of the
system's "never hard-delete support-domain records" convention.
## Requirements *(mandatory)*
### Functional Requirements
- **FR-001**: The system MUST let an admin create a knowledge entry scoped to a product, and
optionally to a feature and one or more categories.
- **FR-002**: A knowledge entry MUST have a `status` of `draft`, `published`, or `unpublished`,
and MUST default to `draft` on creation.
- **FR-003**: A knowledge entry MUST only be returned by retrieval when its status is
`published` AND its effective date is at or before the current time.
- **FR-004**: Editing a published knowledge entry's content MUST create a new version
(incrementing a version counter) rather than overwriting the existing version in place; prior
versions MUST remain retrievable by their own identity.
- **FR-005**: A knowledge entry MUST carry a `validationStatus` (`unvalidated` or `validated`),
independently settable by an admin from its publish status.
- **FR-006**: The system MUST let an admin create an error code scoped to a product, unique
within that product.
- **FR-007**: The system MUST let an admin create a known issue referencing an error code, and
retrieve a known issue directly by its error code.
- **FR-008**: The system MUST let an admin create a runbook for a product as an ordered list of
steps, preserving the authored order exactly.
- **FR-009**: Editing a runbook's steps MUST create a new version, matching FR-004's behavior for
knowledge entries.
- **FR-010**: A runbook MUST have an active/inactive state; lookup by key MUST NOT return an
inactive runbook, and MUST NOT distinguish "inactive" from "does not exist" in its response.
- **FR-011**: A retrieval query MUST be scoped to at least a product, and MAY be further filtered
by feature and/or category; it MUST NEVER return entries outside the specified product scope.
- **FR-012**: A retrieval query MUST NEVER return a `draft` or `unpublished` entry, or an entry
whose effective date has not yet arrived.
- **FR-013**: When multiple retrieved entries are otherwise equally relevant to a query, entries
with `validationStatus: validated` MUST be ranked ahead of unvalidated ones.
- **FR-014**: Every knowledge entry MUST carry `owner` and `lastReview` fields an admin can set,
supporting future staleness detection — this feature does not implement staleness detection
itself, only the fields it depends on.
### Key Entities
- **Knowledge Entry**: A versioned, product-scoped piece of guidance (known issue, FAQ,
resolution procedure, or operations note) with a draft/published/unpublished lifecycle, a
validation status, and retrieval-filtering scope (product/feature/category).
- **Error Code**: A structured, product-scoped error identifier (e.g. `LAYOUT_PARSE_042`) with a
description, referenced by known issues.
- **Known Issue**: A product-scoped problem record, optionally linked to an Error Code,
describing a recognized issue and its status.
- **Runbook**: A versioned, ordered sequence of troubleshooting steps for a product, looked up by
a stable key, with an active/inactive state — the step *sequence itself* is data owned by this
feature; *executing* a runbook against a live conversation is the future AI-support feature's
job, not this one's.
## Success Criteria *(mandatory)*
### Measurable Outcomes
- **SC-001**: 100% of draft knowledge entries are absent from every retrieval query result,
verified across every entry type.
- **SC-002**: 100% of retrieval queries scoped to one product return zero entries belonging to
any other product.
- **SC-003**: An admin can publish a knowledge entry and have it appear in retrieval results
within the same request cycle — no propagation delay.
- **SC-004**: Editing a published entry never loses the prior version's content — it remains
retrievable by an admin after the edit, 100% of the time.
- **SC-005**: A validated entry is ranked ahead of an equally-matching unvalidated one in 100% of
retrieval results that include both.
- **SC-006**: A known issue is resolvable by its error code in a single lookup, without a
separate search step.
## Assumptions
- **Full semantic (embedding/vector) retrieval is explicitly out of scope for this feature** —
per `docs/11-architect-additions-gaps-and-recommendations.md` §B1, the embedding model,
chunking strategy, and re-ranking approach are technical decisions for the future AI-support
feature (Phase 4) to make, not this one. This feature implements retrieval as structured,
deterministic filtering (product/feature/category scope, status, effective date,
validation-status preference) — a real, usable retrieval contract, not a placeholder — that a
future semantic layer can sit in front of without changing the underlying data model or the
guarantee that filters apply before any ranking (doc 11 §B1's explicit ordering requirement).
- Runbook *execution* (the workflow engine that controls which step is permitted next during a
live AI conversation, per `docs/03-ai-support-architecture.md` §6) is explicitly out of scope —
this feature only stores and versions the step data; the future AI-support feature interprets
and executes it.
- Content-quality validation (e.g. requiring non-empty fields before publish) is not enforced by
this feature — an admin can publish sparse content; a content-review workflow is not part of
this phase.
- Deletion of knowledge entries, error codes, known issues, or runbooks is out of scope — only
publish/unpublish and active/inactive state changes are defined, consistent with this system's
broader convention of never silently losing support-domain history.

Some files were not shown because too many files have changed in this diff Show More