From 967a3aab83bf4347376b6bab6027c6cd1d50d8a2 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:22:16 +0000 Subject: [PATCH 01/45] fix: update scrutiny areas for draft edit concurrency and renumber sections --- .../references/scrutiny-areas.md | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/.github/skills/run-spec-audit/references/scrutiny-areas.md b/.github/skills/run-spec-audit/references/scrutiny-areas.md index 0b3b5d34b..f6d2a8ea1 100644 --- a/.github/skills/run-spec-audit/references/scrutiny-areas.md +++ b/.github/skills/run-spec-audit/references/scrutiny-areas.md @@ -57,7 +57,18 @@ maintenance rule. - **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 4"` -## 5. Package-Local Requirements and Deviations — Scenario 6, 7 +## 5. Draft Edit Concurrency — Scenario 11 + +- **Code:** `lib/dal/requirements.ts` and + `lib/requirements/service.ts` — optimistic edit preconditions. +- **Spec:** `docs/lifecycle-workflow.md`. +- **Req tag:** `[Req: formal — docs/lifecycle-workflow.md "Draft"]` +- **Question:** Are stale draft edits rejected before content or joins are + rewritten? +- **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts + -t "Scenario 11"` + +## 6. Package-Local Requirements and Deviations — Scenario 6, 7 - **Code:** `lib/dal/requirement-packages.ts` — package-local requirement, needs-reference, and deviation-gated status functions. @@ -73,7 +84,7 @@ maintenance rule. - **Verify (S7):** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 7"` -## 6. Deviation Lifecycle Guards and Decision Immutability — Scenario 9 +## 7. Deviation Lifecycle Guards and Decision Immutability — Scenario 9 - **Code:** `lib/dal/deviations.ts` — approval/rejection logic, review-requested guards, edit/delete guards. @@ -87,7 +98,7 @@ maintenance rule. - **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 9"` -## 7. Suggestion Terminal State — Scenario 8 +## 8. Suggestion Terminal State — Scenario 8 - **Code:** `lib/dal/improvement-suggestions.ts` — resolution logic. - **Spec:** `docs/lifecycle-workflow.md`. @@ -98,7 +109,7 @@ maintenance rule. - **Verify:** `npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 8"` -## 8. List View Defensive Parsing +## 9. List View Defensive Parsing - **Code:** `lib/requirements/list-view.ts`. - **Spec:** `docs/requirements-ui-behaviour.md` and @@ -106,7 +117,7 @@ maintenance rule. - **Question:** Do malformed admin defaults, invalid visible-column JSON, hidden filters, or bad widths fail safely? -## 9. REST and MCP Output Consistency +## 10. REST and MCP Output Consistency - **Code:** `lib/mcp/http.ts`, `lib/mcp/server.ts`, `app/api/requirements/[id]/route.ts`, @@ -118,7 +129,7 @@ maintenance rule. - **Question:** Do REST and MCP outputs, transport rules, and field names match the documentation and field contracts? -## 10. CSV Export +## 11. CSV Export - **Code:** `lib/export-csv.ts`. - **Spec:** `docs/reports.md`. @@ -127,7 +138,7 @@ maintenance rule. - **Question:** Does export behavior match the documented CSV expectations for separators and escaping? -## 11. Coverage Target Alignment +## 12. Coverage Target Alignment - **Source:** `tests/quality/QUALITY.md` — Coverage Targets table. - **Question:** Do the subsystems and file paths listed in the @@ -135,7 +146,7 @@ maintenance rule. Flag new DAL files, renamed modules, or removed subsystems that make the targets stale. -## 12. MCP Tool Inventory Parity — Scenario 10 +## 13. MCP Tool Inventory Parity — Scenario 10 - **Code:** `lib/mcp/server.ts`. - **Spec:** `docs/mcp-server-contributor-guide.md` ("Server Contract", @@ -161,7 +172,7 @@ This file must stay in sync with `tests/quality/QUALITY.md`: references here. - See `tests/quality/AGENTS.md` for the authoritative sync rule. -## 13. Reference Data Behavioral Contracts +## 14. Reference Data Behavioral Contracts - **Code:** `lib/dal/norm-references.ts`, `lib/dal/owners.ts`, `lib/dal/package-implementation-types.ts`, @@ -176,7 +187,7 @@ This file must stay in sync with `tests/quality/QUALITY.md`: (`nameSv` for taxonomy, `normReferenceId` for norm references, `lastName`/`firstName` for owners)? -## 14. AI Generation Contracts +## 15. AI Generation Contracts - **Code:** `lib/ai/openrouter-client.ts`, `lib/ai/requirement-prompt.ts`, `lib/ai/taxonomy.ts`. From 893635c7bcc1f51b38ddd250f5a5df7845900319 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:25:20 +0000 Subject: [PATCH 02/45] feat: add expectedEditedAt handling and stale edit conflict notifications --- .../[id]/edit/edit-requirement-client.tsx | 5 ++ app/api/requirements/[id]/route.ts | 6 ++ components/RequirementForm.tsx | 83 ++++++++++++++++++- lib/dal/requirements.ts | 80 ++++++++++++++++-- lib/mcp/server.ts | 1 + lib/requirements/service.ts | 53 ++++++++---- messages/en.json | 4 + messages/sv.json | 4 + 8 files changed, 212 insertions(+), 24 deletions(-) diff --git a/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx b/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx index 5dc90040c..0ee643234 100644 --- a/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx +++ b/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx @@ -46,6 +46,7 @@ export default function EditRequirementClient({ number[] >([]) const [initialScenarioIds, setInitialScenarioIds] = useState([]) + const [expectedEditedAt, setExpectedEditedAt] = useState(null) const [uniqueId, setUniqueId] = useState('') const [isPublished, setIsPublished] = useState(false) const [loading, setLoading] = useState(true) @@ -53,6 +54,7 @@ export default function EditRequirementClient({ const fetchData = useCallback(async () => { setFetchError(null) + setExpectedEditedAt(null) setIsPublished(false) setLoading(true) @@ -84,6 +86,7 @@ export default function EditRequirementClient({ return } setIsPublished(latest.status === STATUS_PUBLISHED) + setExpectedEditedAt(latest.editedAt) setInitialData({ areaId: data.area?.id != null ? String(data.area.id) : '', categoryId: @@ -169,10 +172,12 @@ export default function EditRequirementClient({ )}
diff --git a/app/api/requirements/[id]/route.ts b/app/api/requirements/[id]/route.ts index 84c8296a7..e82622bb5 100644 --- a/app/api/requirements/[id]/route.ts +++ b/app/api/requirements/[id]/route.ts @@ -67,6 +67,12 @@ export async function PUT( categoryId: body.categoryId ? Number(body.categoryId) : undefined, createdBy: body.ownerId ? String(body.ownerId) : undefined, description: String(body.description ?? ''), + expectedEditedAt: + body.expectedEditedAt === null + ? null + : body.expectedEditedAt != null + ? String(body.expectedEditedAt) + : undefined, normReferenceIds: Array.isArray(body.normReferenceIds) ? body.normReferenceIds .map(value => Number(value)) diff --git a/components/RequirementForm.tsx b/components/RequirementForm.tsx index 9db01ad28..799ffa053 100644 --- a/components/RequirementForm.tsx +++ b/components/RequirementForm.tsx @@ -1,7 +1,7 @@ 'use client' import { motion } from 'framer-motion' -import { Plus, X } from 'lucide-react' +import { AlertTriangle, ExternalLink, Plus, RotateCcw, X } from 'lucide-react' import { useTranslations } from 'next-intl' import { useEffect, useRef, useState } from 'react' import { createPortal } from 'react-dom' @@ -11,17 +11,29 @@ import RequirementFormFields, { } from '@/components/RequirementFormFields' import { useRouter } from '@/i18n/routing' import { apiFetch } from '@/lib/http/api-fetch' +import type { RequirementDetailResponse } from '@/lib/requirements/types' interface RequirementFormProps { + expectedEditedAt?: string | null initialData?: Partial< Omit > initialNormReferenceIds?: number[] initialScenarioIds?: number[] mode: 'create' | 'edit' + onRefreshLatest?: () => Promise | void requirementId?: number | string } +interface RequirementEditErrorPayload { + code?: string + details?: { + latest?: RequirementDetailResponse | null + reason?: string + } + error?: string +} + interface NormReferenceOption { id: number issuer: string @@ -46,9 +58,11 @@ const EMPTY_FORM: RequirementFormFieldValues = { } export default function RequirementForm({ + expectedEditedAt, initialData, initialNormReferenceIds, initialScenarioIds, + onRefreshLatest, requirementId, mode, }: RequirementFormProps) { @@ -74,6 +88,9 @@ export default function RequirementForm({ const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) + const [staleConflict, setStaleConflict] = useState<{ + latest: RequirementDetailResponse | null + } | null>(null) const [saveDestination, setSaveDestination] = useState<'inline' | 'page'>( () => { try { @@ -138,6 +155,7 @@ export default function RequirementForm({ e.preventDefault() setSubmitting(true) setError(null) + setStaleConflict(null) try { const url = @@ -156,6 +174,7 @@ export default function RequirementForm({ : undefined, riskLevelId: form.riskLevelId ? Number(form.riskLevelId) : undefined, description: form.description || undefined, + expectedEditedAt: mode === 'edit' ? expectedEditedAt : undefined, acceptanceCriteria: form.acceptanceCriteria || undefined, requiresTesting: form.requiresTesting, verificationMethod: form.requiresTesting @@ -198,9 +217,18 @@ export default function RequirementForm({ router.push(`/requirements?selected=${targetUniqueId}`) } } else { - const err = (await res.json().catch(() => null)) as { - error?: string - } | null + const err = (await res + .json() + .catch(() => null)) as RequirementEditErrorPayload | null + if ( + res.status === 409 && + err?.code === 'conflict' && + err.details?.reason === 'stale_requirement_edit' + ) { + setStaleConflict({ latest: err.details.latest ?? null }) + setError(null) + return + } setError(err?.error ?? res.statusText) } } catch (e) { @@ -210,6 +238,13 @@ export default function RequirementForm({ } } + const latestConflictVersion = staleConflict?.latest?.versions[0] + const latestConflictTarget = + staleConflict?.latest?.uniqueId ?? String(requirementId ?? '') + const latestConflictHref = latestConflictVersion?.versionNumber + ? `/requirements/${latestConflictTarget}/${latestConflictVersion.versionNumber}` + : `/requirements/${latestConflictTarget}` + const normReferenceCreateButton = ( + {onRefreshLatest && ( + + )} + + + + + )} + {error && (

{error} diff --git a/lib/dal/requirements.ts b/lib/dal/requirements.ts index cc10a4a2a..21b095ce2 100644 --- a/lib/dal/requirements.ts +++ b/lib/dal/requirements.ts @@ -476,6 +476,7 @@ interface RequirementMutationData { acceptanceCriteria?: string createdBy?: string description: string + expectedEditedAt?: string | null normReferenceIds?: number[] qualityCharacteristicId?: number requirementAreaId: number @@ -681,6 +682,7 @@ interface VersionLite { archiveInitiatedAt: string | null createdBy: string | null description: string + editedAt: string | null id: number qualityCharacteristicId: number | null requirementCategoryId: number | null @@ -702,6 +704,7 @@ async function getLatestVersionLite( archive_initiated_at AS archiveInitiatedAt, description, acceptance_criteria AS acceptanceCriteria, + edited_at AS editedAt, requirement_category_id AS requirementCategoryId, requirement_type_id AS requirementTypeId, quality_characteristic_id AS qualityCharacteristicId, @@ -721,6 +724,7 @@ async function getLatestVersionLite( statusId: Number(row.statusId), archiveInitiatedAt: toIso(row.archiveInitiatedAt), description: String(row.description ?? ''), + editedAt: toIso(row.editedAt), acceptanceCriteria: row.acceptanceCriteria == null ? null : String(row.acceptanceCriteria), requirementCategoryId: toNum(row.requirementCategoryId), @@ -734,6 +738,45 @@ async function getLatestVersionLite( } } +function normalizeExpectedEditedAt(value: string | null | undefined) { + if (value === undefined) { + throw validationError( + 'Edit operation requires requirement.expectedEditedAt', + ) + } + if (value === null) return null + const date = new Date(value) + if (Number.isNaN(date.getTime())) { + throw validationError( + 'Edit operation requires a valid requirement.expectedEditedAt timestamp', + ) + } + return date.toISOString() +} + +function hasMatchingEditedAt(current: string | null, expected: string | null) { + if (current == null || expected == null) { + return current == null && expected == null + } + return new Date(current).getTime() === new Date(expected).getTime() +} + +function staleRequirementEditError( + requirementId: number, + expectedEditedAt: string | null, + latestEditedAt: string | null, +) { + return conflictError( + 'This requirement was updated after you started editing. Refresh to review the latest version before saving.', + { + expectedEditedAt, + latestEditedAt, + reason: 'stale_requirement_edit', + requirementId, + }, + ) +} + export async function editRequirement( db: SqlServerDatabase, requirementId: number, @@ -743,6 +786,7 @@ export async function editRequirement( ): Promise { const scenarioIds = uniqueIds(data.scenarioIds) const normRefIds = uniqueIds(data.normReferenceIds) + const expectedEditedAt = normalizeExpectedEditedAt(data.expectedEditedAt) const now = new Date() const verificationMethod = data.requiresTesting ? (data.verificationMethod ?? null) @@ -767,11 +811,11 @@ export async function editRequirement( 'Cannot edit an archived requirement — restore it first', ) } - - if (data.requirementAreaId != null) { - await tx.query( - `UPDATE requirements SET requirement_area_id = @0 WHERE id = @1`, - [data.requirementAreaId, requirementId], + if (!hasMatchingEditedAt(current.editedAt, expectedEditedAt)) { + throw staleRequirementEditError( + requirementId, + data.expectedEditedAt ?? null, + current.editedAt, ) } @@ -788,7 +832,11 @@ export async function editRequirement( verification_method = @7, edited_at = @8 ${VERSION_OUTPUT} - WHERE id = @9`, + WHERE id = @9 + AND ( + (@10 IS NULL AND edited_at IS NULL) + OR edited_at = CONVERT(datetime2, @10, 127) + )`, [ data.description, data.acceptanceCriteria ?? null, @@ -800,10 +848,24 @@ export async function editRequirement( verificationMethod, now, current.id, + expectedEditedAt, ], )) as Array> + if (!updateRows[0]) { + throw staleRequirementEditError( + requirementId, + data.expectedEditedAt ?? null, + current.editedAt, + ) + } result = mapVersion(updateRows[0] ?? {}) + if (data.requirementAreaId != null) { + await tx.query( + `UPDATE requirements SET requirement_area_id = @0 WHERE id = @1`, + [data.requirementAreaId, requirementId], + ) + } await tx.query( `DELETE FROM requirement_version_usage_scenarios WHERE requirement_version_id = @0`, [current.id], @@ -821,6 +883,12 @@ export async function editRequirement( tx, requirementId, ) + if (data.requirementAreaId != null) { + await tx.query( + `UPDATE requirements SET requirement_area_id = @0 WHERE id = @1`, + [data.requirementAreaId, requirementId], + ) + } const insertRows = (await tx.query( `INSERT INTO requirement_versions ( requirement_id, version_number, description, acceptance_criteria, diff --git a/lib/mcp/server.ts b/lib/mcp/server.ts index 54c28c46b..b8507551d 100644 --- a/lib/mcp/server.ts +++ b/lib/mcp/server.ts @@ -408,6 +408,7 @@ const RequirementMutationSchema = z categoryId: z.number().int().positive().optional(), createdBy: z.string().max(200).optional(), description: z.string().max(4000).optional(), + expectedEditedAt: z.string().max(80).nullable().optional(), requiresTesting: z.boolean().optional(), verificationMethod: z.string().max(4000).optional(), scenarioIds: z.array(z.number().int().positive()).optional(), diff --git a/lib/requirements/service.ts b/lib/requirements/service.ts index 8b0ce21ef..6f977290b 100644 --- a/lib/requirements/service.ts +++ b/lib/requirements/service.ts @@ -79,6 +79,7 @@ import { type RequirementsAction, } from '@/lib/requirements/auth' import { + conflictError, internalError, isRequirementsServiceError, notFoundError, @@ -123,6 +124,7 @@ export interface RequirementMutationInput { categoryId?: number createdBy?: string description?: string + expectedEditedAt?: string | null normReferenceIds?: number[] qualityCharacteristicId?: number requiresTesting?: boolean @@ -1316,23 +1318,46 @@ export function createRequirementsService( 'Edit operation requires requirement.description', ) } + if (payload.expectedEditedAt === undefined) { + throw validationError( + 'Edit operation requires requirement.expectedEditedAt', + { reason: 'missing_edit_precondition' }, + ) + } if (payload.areaId != null) { await ensureAreaExists(db, payload.areaId) } - const version = await editRequirement(db, requirementId, { - acceptanceCriteria: payload.acceptanceCriteria, - createdBy: payload.createdBy ?? context.actor.id ?? undefined, - description: payload.description, - normReferenceIds: payload.normReferenceIds, - requirementAreaId: payload.areaId, - requirementCategoryId: payload.categoryId, - qualityCharacteristicId: payload.qualityCharacteristicId, - requirementTypeId: payload.typeId, - requiresTesting: payload.requiresTesting, - riskLevelId: payload.riskLevelId, - verificationMethod: payload.verificationMethod, - scenarioIds: payload.scenarioIds, - }) + let version: Awaited> + try { + version = await editRequirement(db, requirementId, { + acceptanceCriteria: payload.acceptanceCriteria, + createdBy: payload.createdBy ?? context.actor.id ?? undefined, + description: payload.description, + expectedEditedAt: payload.expectedEditedAt, + normReferenceIds: payload.normReferenceIds, + requirementAreaId: payload.areaId, + requirementCategoryId: payload.categoryId, + qualityCharacteristicId: payload.qualityCharacteristicId, + requirementTypeId: payload.typeId, + requiresTesting: payload.requiresTesting, + riskLevelId: payload.riskLevelId, + verificationMethod: payload.verificationMethod, + scenarioIds: payload.scenarioIds, + }) + } catch (error) { + if ( + isRequirementsServiceError(error) && + error.code === 'conflict' && + error.details?.reason === 'stale_requirement_edit' + ) { + const latest = await getRequirementById(db, requirementId) + throw conflictError(error.message, { + ...error.details, + latest: latest ? formatRequirementDetail(latest) : null, + }) + } + throw error + } const detail = formatRequirementDetail( (await getRequirementById(db, requirementId)) ?? (() => { diff --git a/messages/en.json b/messages/en.json index 94c48be9c..155b1940f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -203,6 +203,10 @@ "editNotAllowedStatusReview": "This requirement cannot be edited because it is in Review status. Return it to Draft status first.", "editNotAllowedStatusArchived": "This requirement is Archived and must be restored or reactivated before it can be edited.", "editPublishedVersionNotice": "This requirement is Published. Saving will create a new Draft version — the published version remains active until the new draft completes the review process and is published.", + "staleEditConflict": "This requirement changed while you were editing.", + "staleEditConflictHelp": "Your changes are still here. Open the latest saved version to compare, or reload the editor before trying again.", + "staleEditViewLatest": "View latest", + "staleEditReload": "Reload editor", "issuer": "Issuer", "name": "Name", "version": "Version", diff --git a/messages/sv.json b/messages/sv.json index 6e9613ca8..90d365607 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -203,6 +203,10 @@ "editNotAllowedStatusReview": "Detta krav kan inte redigeras eftersom det har status Granskning. Återställ det till Utkast-status först.", "editNotAllowedStatusArchived": "Detta krav är Arkiverat och måste återställas eller återaktiveras innan det kan redigeras.", "editPublishedVersionNotice": "Detta krav är Publicerat. Att spara skapar en ny version i Utkast — den publicerade versionen förblir aktiv tills det nya utkastet genomgått granskningsprocessen och publicerats.", + "staleEditConflict": "Kravet ändrades medan du redigerade.", + "staleEditConflictHelp": "Dina ändringar finns kvar här. Öppna den senaste sparade versionen för att jämföra, eller ladda om redigeraren innan du försöker igen.", + "staleEditViewLatest": "Visa senaste", + "staleEditReload": "Ladda om redigeraren", "issuer": "Utfärdare", "name": "Benämning", "version": "Version", From c23903b72bbc1190d4d5f6366161201ca71b1287 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:25:44 +0000 Subject: [PATCH 03/45] fix: reject stale draft edits to maintain content integrity --- tests/quality/QUALITY.md | 29 ++++++++++++++++++++- tests/quality/functional.test.ts | 44 +++++++++++++++++++++++++++++--- 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/tests/quality/QUALITY.md b/tests/quality/QUALITY.md index 298ff9770..097d89c6a 100644 --- a/tests/quality/QUALITY.md +++ b/tests/quality/QUALITY.md @@ -140,7 +140,7 @@ npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 3: publishi **What happened:** `editRequirement()` rejects edits against review and -archived versions at `lib/dal/requirements.ts:795-805`. If those rows are +archived versions at `lib/dal/requirements.ts:806-812`. If those rows are edited in place, the project loses the meaning of approval, publication, and archival timestamps because the historical record itself mutates. @@ -318,6 +318,33 @@ npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 10: MCP too ``` +--- + +### Scenario 11: Stale Draft Edits Are Rejected Before Replacing Latest Content + +**Requirement tag:** `[Req: formal — docs/lifecycle-workflow.md "Draft"]` + +**What happened:** Draft content is intentionally editable in place, but +`editRequirement()` now requires the caller's `expectedEditedAt` token to match +the latest draft row before it updates the row or rewrites its scenario and norm +reference joins at `lib/dal/requirements.ts:741-875`. The shared service +requires the token and adds the latest snapshot to stale conflict responses at +`lib/requirements/service.ts:1314-1357`. If those guards are removed, a second +editor can silently replace the first editor's saved content while still +receiving a successful response. + +**The requirement:** Editing a draft must be conditional on the `edited_at` +value captured when editing started. A stale edit must fail with a conflict and +must leave the latest saved version content unchanged. + +**How to verify:** + + +```sh +npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 11: stale draft edits are rejected before replacing latest content" +``` + + ## AI Session Quality Discipline 1. Read `tests/quality/QUALITY.md` before changing lifecycle, package, MCP, diff --git a/tests/quality/functional.test.ts b/tests/quality/functional.test.ts index a2c81b4d3..dc5f015ae 100644 --- a/tests/quality/functional.test.ts +++ b/tests/quality/functional.test.ts @@ -67,9 +67,9 @@ import { * Scenario 10 is a pure file-content check and always runs as part of * `npm run test`. * - * Scenarios 1-9 exercise lifecycle invariants that require a real SQL Server - * instance. The harness derives a connection URL automatically from the - * standard DB_* environment variables (the same ones used by the dev + * Scenarios 1-9 and 11 exercise lifecycle invariants that require a real + * SQL Server instance. The harness derives a connection URL automatically from + * the standard DB_* environment variables (the same ones used by the dev * scripts) and swaps the database name to a dedicated * `_functional_tests` instance so the development data is never * touched. Set `SQLSERVER_FUNCTIONAL_TESTS_URL` to override the derived URL, @@ -290,6 +290,7 @@ async function createPublishedRequirement( areaId: number, description: string, ): Promise<{ + editedAt: string | null requirementId: number uniqueId: string publishedVersionId: number @@ -305,6 +306,7 @@ async function createPublishedRequirement( STATUS_PUBLISHED, ) return { + editedAt: published.editedAt, requirementId: created.requirement.id, uniqueId: created.requirement.uniqueId, publishedVersionId: published.id, @@ -362,6 +364,7 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await editRequirement(appDb(), published.requirementId, { description: 'Draft replacement that must stay private', + expectedEditedAt: published.editedAt, }) const service = createRequirementsService(appDb()) @@ -388,6 +391,7 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await editRequirement(appDb(), published.requirementId, { description: 'Pending replacement', + expectedEditedAt: published.editedAt, }) await expect( @@ -409,6 +413,7 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await editRequirement(appDb(), published.requirementId, { description: 'Version two draft', + expectedEditedAt: published.editedAt, }) await transitionStatus(appDb(), published.requirementId, STATUS_REVIEW) const republished = await transitionStatus( @@ -442,6 +447,7 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await expect( editRequirement(appDb(), created.requirement.id, { description: 'Illegal review edit', + expectedEditedAt: created.version.editedAt, }), ).rejects.toMatchObject({ code: 'conflict', @@ -457,6 +463,7 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await expect( editRequirement(appDb(), created.requirement.id, { description: 'Illegal archived edit', + expectedEditedAt: created.version.editedAt, }), ).rejects.toMatchObject({ code: 'conflict', @@ -700,4 +707,35 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { code: 'conflict', }) }) + + it('Scenario 11: stale draft edits are rejected before replacing latest content', async () => { + const area = await createArea(appDb()) + const created = await createRequirement(appDb(), { + description: 'Original draft', + requirementAreaId: area.id, + }) + + const firstSave = await editRequirement(appDb(), created.requirement.id, { + description: 'First saved draft', + expectedEditedAt: created.version.editedAt, + }) + + await expect( + editRequirement(appDb(), created.requirement.id, { + description: 'Stale overwrite attempt', + expectedEditedAt: created.version.editedAt, + }), + ).rejects.toMatchObject({ + code: 'conflict', + details: { reason: 'stale_requirement_edit' }, + }) + + const history = await getVersionHistory(appDb(), created.requirement.id) + expect(history).toHaveLength(1) + expect(history[0]).toMatchObject({ + description: 'First saved draft', + editedAt: firstSave.editedAt, + status: STATUS_DRAFT, + }) + }) }) From 7c1fc82a258e8b70992b05dc25bec49441490587 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:25:56 +0000 Subject: [PATCH 04/45] feat: add expectedEditedAt handling and stale edit conflict management across requirement-related tests --- tests/unit/edit-requirement-client.test.tsx | 16 ++++- tests/unit/mcp-http.test.ts | 2 + tests/unit/requirement-form.test.tsx | 75 +++++++++++++++++++++ tests/unit/requirements-dal.test.ts | 47 ++++++++++++- tests/unit/requirements-id-route.test.ts | 47 ++++++++++++- tests/unit/requirements-service.test.ts | 63 ++++++++++++++++- 6 files changed, 242 insertions(+), 8 deletions(-) diff --git a/tests/unit/edit-requirement-client.test.tsx b/tests/unit/edit-requirement-client.test.tsx index 5ef250653..1c7446fff 100644 --- a/tests/unit/edit-requirement-client.test.tsx +++ b/tests/unit/edit-requirement-client.test.tsx @@ -30,11 +30,13 @@ vi.mock('@/components/HelpPanel', () => ({ vi.mock('@/components/RequirementForm', () => ({ default: (props: { + expectedEditedAt?: string | null mode: string - requirementId?: number + requirementId?: number | string initialData?: Record }) => (

{ }) it('fetches requirement data and renders form', async () => { + fetchMock.mockResolvedValue( + okJson( + makeRequirementDetailResponse( + {}, + { editedAt: '2026-03-08T00:00:00.000Z' }, + ), + ), + ) render() await waitFor(() => { expect(screen.getByTestId('req-form')).toBeInTheDocument() }) expect(screen.getByTestId('req-form')).toHaveAttribute('data-mode', 'edit') + expect(screen.getByTestId('req-form')).toHaveAttribute( + 'data-expected-edited-at', + '2026-03-08T00:00:00.000Z', + ) expect(screen.getByText(/REQ-001/)).toBeInTheDocument() }) diff --git a/tests/unit/mcp-http.test.ts b/tests/unit/mcp-http.test.ts index 96b481347..9c0369616 100644 --- a/tests/unit/mcp-http.test.ts +++ b/tests/unit/mcp-http.test.ts @@ -535,6 +535,7 @@ describe('handleRequirementsMcpRequest', () => { uniqueId: 'INT0001', requirement: { description: 'Updated description', + expectedEditedAt: '2026-03-08T00:00:00.000Z', normReferenceIds: [1, 2], }, }, @@ -546,6 +547,7 @@ describe('handleRequirementsMcpRequest', () => { expect.anything(), expect.objectContaining({ requirement: expect.objectContaining({ + expectedEditedAt: '2026-03-08T00:00:00.000Z', normReferenceIds: [1, 2], }), }), diff --git a/tests/unit/requirement-form.test.tsx b/tests/unit/requirement-form.test.tsx index 389649ae8..dfda82894 100644 --- a/tests/unit/requirement-form.test.tsx +++ b/tests/unit/requirement-form.test.tsx @@ -27,6 +27,10 @@ function okJson(body: unknown) { return { ok: true, json: async () => body } } +function errJson(body: unknown, status = 400, statusText = 'Bad Request') { + return { ok: false, json: async () => body, status, statusText } +} + const fetchMock = vi.fn() vi.stubGlobal('fetch', fetchMock) @@ -168,6 +172,7 @@ describe('RequirementForm', () => { const { container } = render( { expect.objectContaining({ method: 'PUT' }), ) }) + const putCall = fetchMock.mock.calls.find( + (c: unknown[]) => + c[0] === '/api/requirements/5' && + (c[1] as RequestInit)?.method === 'PUT', + ) + const body = JSON.parse((putCall?.[1] as RequestInit).body as string) + expect(body.expectedEditedAt).toBe('2026-03-08T00:00:00.000Z') + }) + + it('shows a stale edit conflict prompt without clearing form data', async () => { + fetchMock.mockImplementation((url: string, opts?: RequestInit) => { + if (opts?.method === 'PUT') + return Promise.resolve( + errJson( + { + code: 'conflict', + details: { + latest: { + uniqueId: 'REQ-001', + versions: [{ versionNumber: 2 }], + }, + reason: 'stale_requirement_edit', + }, + error: 'This requirement was updated', + }, + 409, + 'Conflict', + ), + ) + if (typeof url === 'string' && url.includes('/api/requirement-areas')) + return Promise.resolve(okJson({ areas: sampleAreas })) + if ( + typeof url === 'string' && + url.includes('/api/requirement-categories') + ) + return Promise.resolve(okJson({ categories: sampleCategories })) + if ( + typeof url === 'string' && + url.includes('/api/quality-characteristics') + ) + return Promise.resolve(okJson({ qualityCharacteristics: [] })) + if (typeof url === 'string' && url.includes('/api/requirement-types')) + return Promise.resolve(okJson({ types: sampleTypes })) + return Promise.resolve(okJson({})) + }) + + const { container } = render( + , + ) + + const desc = await screen.findByRole('textbox', { + name: /requirement\.description/, + }) + fireEvent.change(desc, { target: { value: 'Unsaved local text' } }) + fireEvent.submit(container.querySelector('form') as HTMLFormElement) + + await waitFor(() => { + expect(screen.getByRole('alert')).toHaveTextContent( + 'requirement.staleEditConflict', + ) + }) + expect(desc).toHaveValue('Unsaved local text') + expect( + screen.getByRole('button', { name: /requirement\.staleEditViewLatest/ }), + ).toBeInTheDocument() }) it('navigates back on cancel', async () => { diff --git a/tests/unit/requirements-dal.test.ts b/tests/unit/requirements-dal.test.ts index 1a00a1a11..060d20e44 100644 --- a/tests/unit/requirements-dal.test.ts +++ b/tests/unit/requirements-dal.test.ts @@ -1,5 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { + editRequirement, getRequirementById, getRequirementByUniqueId, } from '@/lib/dal/requirements' @@ -8,12 +9,17 @@ function createSqlServerDb() { const query = vi.fn<(sql: string, parameters?: unknown[]) => Promise>() const getRepository = vi.fn() + const transaction = vi.fn( + async (callback: (manager: { query: typeof query }) => Promise) => + callback({ query }), + ) const db = { getRepository, query, + transaction, } as unknown as Parameters[0] - return { db, query } + return { db, query, transaction } } describe('requirements DAL (SQL Server path)', () => { @@ -223,4 +229,43 @@ describe('requirements DAL (SQL Server path)', () => { await expect(getRequirementByUniqueId(db, 'NONE')).resolves.toBeNull() expect(query).toHaveBeenCalledTimes(1) }) + + it('rejects stale draft edits before rewriting joins', async () => { + const { db, query } = createSqlServerDb() + const editedAt = new Date('2026-04-20T08:30:00.000Z') + query + .mockResolvedValueOnce([ + { + id: 21, + statusId: 1, + editedAt, + }, + ]) + .mockResolvedValueOnce([]) + + await expect( + editRequirement(db, 7, { + description: 'Stale update', + expectedEditedAt: editedAt.toISOString(), + normReferenceIds: [100], + scenarioIds: [200], + }), + ).rejects.toMatchObject({ + code: 'conflict', + details: { reason: 'stale_requirement_edit' }, + }) + + const sqlCalls = query.mock.calls.map(([sql]) => String(sql)) + expect(sqlCalls[1]).toContain('edited_at = CONVERT(datetime2, @10, 127)') + expect( + sqlCalls.some(sql => + sql.includes('DELETE FROM requirement_version_usage_scenarios'), + ), + ).toBe(false) + expect( + sqlCalls.some(sql => + sql.includes('DELETE FROM requirement_version_norm_references'), + ), + ).toBe(false) + }) }) diff --git a/tests/unit/requirements-id-route.test.ts b/tests/unit/requirements-id-route.test.ts index b05799956..e42b9c0d4 100644 --- a/tests/unit/requirements-id-route.test.ts +++ b/tests/unit/requirements-id-route.test.ts @@ -21,9 +21,15 @@ vi.mock('@/lib/requirements/service', () => ({ getRequirement: mockGetRequirement, manageRequirement: mockManageRequirement, }), - toHttpErrorPayload: (err: Error) => ({ - body: { error: err.message }, - status: 400, + toHttpErrorPayload: ( + err: Error & { + code?: string + details?: Record + status?: number + }, + ) => ({ + body: { code: err.code, details: err.details, error: err.message }, + status: err.status ?? 400, }), })) @@ -102,6 +108,7 @@ describe('requirements/[id] route', () => { method: 'PUT', body: JSON.stringify({ description: 'Updated', + expectedEditedAt: '2026-03-08T00:00:00.000Z', references: [{ name: 'Ref1', uri: 'http://example.com' }], scenarioIds: [1, 2], }), @@ -117,10 +124,44 @@ describe('requirements/[id] route', () => { expect.objectContaining({ id: 1, operation: 'edit', + requirement: expect.objectContaining({ + expectedEditedAt: '2026-03-08T00:00:00.000Z', + }), }), ) }) + it('returns stale edit conflicts with details from the service', async () => { + mockManageRequirement.mockRejectedValue( + Object.assign(new Error('This requirement was updated'), { + code: 'conflict', + details: { + latest: { uniqueId: 'REQ-001' }, + reason: 'stale_requirement_edit', + }, + status: 409, + }), + ) + + const req = new NextRequest('http://localhost/api/requirements/1', { + method: 'PUT', + body: JSON.stringify({ + description: 'Updated', + expectedEditedAt: '2026-03-08T00:00:00.000Z', + }), + headers: { 'Content-Type': 'application/json' }, + }) + const res = await PUT(req, makeParams('1')) + const json = (await res.json()) as { + code: string + details: { reason: string } + } + + expect(res.status).toBe(409) + expect(json.code).toBe('conflict') + expect(json.details.reason).toBe('stale_requirement_edit') + }) + it('returns error on failure', async () => { mockManageRequirement.mockRejectedValue(new Error('Validation')) diff --git a/tests/unit/requirements-service.test.ts b/tests/unit/requirements-service.test.ts index 1c3eaff6a..38e6fee92 100644 --- a/tests/unit/requirements-service.test.ts +++ b/tests/unit/requirements-service.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { forbiddenError } from '@/lib/requirements/errors' +import { conflictError, forbiddenError } from '@/lib/requirements/errors' import { normalizeUiTerminology } from '@/lib/ui-terminology' const mocks = vi.hoisted(() => ({ @@ -713,10 +713,67 @@ describe('createRequirementsService', () => { const result = await service.manageRequirement(makeContext(), { id: 1, operation: 'edit', - requirement: { description: 'Updated text' }, + requirement: { + description: 'Updated text', + expectedEditedAt: '2026-03-08T00:00:00.000Z', + }, }) expect(result.operation).toBe('edit') - expect(mocks.editRequirement).toHaveBeenCalled() + expect(mocks.editRequirement).toHaveBeenCalledWith( + expect.anything(), + 1, + expect.objectContaining({ + expectedEditedAt: '2026-03-08T00:00:00.000Z', + }), + ) + }) + + it('rejects edits without an optimistic concurrency token', async () => { + const service = createRequirementsService({} as never, { + logger, + uiSettings: makeUiSettings(), + }) + + await expect( + service.manageRequirement(makeContext(), { + id: 1, + operation: 'edit', + requirement: { description: 'Updated text' }, + }), + ).rejects.toMatchObject({ + code: 'validation', + details: { reason: 'missing_edit_precondition' }, + }) + expect(mocks.editRequirement).not.toHaveBeenCalled() + }) + + it('adds the latest requirement snapshot to stale edit conflicts', async () => { + mocks.editRequirement.mockRejectedValue( + conflictError('This requirement was updated after you started editing.', { + reason: 'stale_requirement_edit', + }), + ) + const service = createRequirementsService({} as never, { + logger, + uiSettings: makeUiSettings(), + }) + + await expect( + service.manageRequirement(makeContext(), { + id: 1, + operation: 'edit', + requirement: { + description: 'Updated text', + expectedEditedAt: '2026-03-08T00:00:00.000Z', + }, + }), + ).rejects.toMatchObject({ + code: 'conflict', + details: { + latest: expect.objectContaining({ uniqueId: 'INT0001' }), + reason: 'stale_requirement_edit', + }, + }) }) it('initiates archiving review for a requirement', async () => { From e6f2ced6dbcfc89fe6ddc978b5e1595b688b8e5b Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:26:24 +0000 Subject: [PATCH 05/45] fix: enhance optimistic concurrency handling for edited_at updates in version lifecycle --- docs/version-lifecycle-dates.md | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/version-lifecycle-dates.md b/docs/version-lifecycle-dates.md index 0076006ab..06a6035d3 100644 --- a/docs/version-lifecycle-dates.md +++ b/docs/version-lifecycle-dates.md @@ -48,6 +48,11 @@ Editing is **not allowed** when the current version is in Review or Archived status. Review must first be moved back to Draft; Archived must be restored (which creates a new Draft version). +Edit requests must include the `edited_at` value that was current +when editing started. The server treats that value as an optimistic +concurrency token and rejects the save with `409 Conflict` if another +save has changed `edited_at` before the request arrives. + ## When `edited_at` Is Updated `edited_at` is set **only** when user-initiated content fields @@ -251,10 +256,12 @@ The version history pills show the relevant date per status: `published_at` and `archived_at` are `NULL`. - **Editing a requirement** (`editRequirement`): When the current version is Draft, updates the existing row in place with - `edited_at` set to the current time. When the current version - is Published, creates a new Draft version with `edited_at` set - to the current time. **Not allowed** when the current version - is in Review or Archived status. + `edited_at` set to the current time, but only when the caller's + `expectedEditedAt` value still matches the row's previous + `edited_at`. When the current version is Published, creates a new + Draft version with `edited_at` set to the current time after the + same precondition check. **Not allowed** when the current version is + in Review or Archived status. - **Transitioning status** (`transitionStatus`): In-place `UPDATE` on the existing version row. Sets `statusId` to the target status. Sets `published_at` or `archived_at` when From 0c427c30727741c7a52ac71cf5ffca6f1a09ab9c Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:26:37 +0000 Subject: [PATCH 06/45] fix: clarify draft editing requirements and stale draft rejection in lifecycle documentation --- docs/lifecycle-workflow.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/lifecycle-workflow.md b/docs/lifecycle-workflow.md index 72c6f8d63..a6071c9da 100644 --- a/docs/lifecycle-workflow.md +++ b/docs/lifecycle-workflow.md @@ -20,7 +20,9 @@ stateDiagram-v2 ``` - **Draft:** Initial state. The requirement is being - authored or revised. + authored or revised. Saving an edit requires the `edited_at` + value that was current when editing started; stale draft saves + are rejected instead of overwriting newer content. - **Review:** The requirement is under review. This state is used both for publishing review (Draft → Review) and archiving review (Published → Review). The system From 0f494cf87cb2eea0d7c69a93e16482aa11f67ab9 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:34:18 +0000 Subject: [PATCH 07/45] fix: document requirement.expectedEditedAt usage and stale token handling in contributor guide --- docs/mcp-server-contributor-guide.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/mcp-server-contributor-guide.md b/docs/mcp-server-contributor-guide.md index 8648afd1c..91237da3f 100644 --- a/docs/mcp-server-contributor-guide.md +++ b/docs/mcp-server-contributor-guide.md @@ -117,6 +117,11 @@ Supports: - `delete_draft` - `restore_version` +Edit calls must include `requirement.expectedEditedAt`, copied from the +latest version's `editedAt` value returned by `requirements_get_requirement`. +The shared service maps stale tokens to `409 Conflict` details with +`reason: "stale_requirement_edit"` and the latest requirement snapshot. + The shared service also supports `reactivate` for REST parity, but that operation is intentionally not exposed as an MCP tool operation in v1. From 1317e863966f1db99017bd8aa516d5e97d15d4aa Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Fri, 24 Apr 2026 17:34:26 +0000 Subject: [PATCH 08/45] fix: clarify requirement management edit process and handle stale edits in user guide --- docs/mcp-server-user-guide.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/mcp-server-user-guide.md b/docs/mcp-server-user-guide.md index c2c75d4b9..66be7f7e5 100644 --- a/docs/mcp-server-user-guide.md +++ b/docs/mcp-server-user-guide.md @@ -30,7 +30,8 @@ agents can use it reliably. history. - `requirements_manage_requirement` Create, edit, archive, delete the latest draft, or restore a historical - version. + version. For `operation: "edit"`, first fetch the requirement and pass the + version's current `editedAt` value back as `requirement.expectedEditedAt`. - `requirements_transition_requirement` Move a requirement through the lifecycle using a target status ID. @@ -324,6 +325,11 @@ data first: This is especially useful because transitions use `toStatusId`, and creation or editing may require IDs for areas and classification fields. +For edits, also fetch the requirement immediately before preparing the edit. +Use the latest version's `editedAt` value as `requirement.expectedEditedAt`. +If the server returns `409 Conflict` with `reason: "stale_requirement_edit"`, +read the returned latest snapshot and compare before retrying. + ### 2. Prefer `uniqueId` Use stable IDs such as `INT0001` when possible. The server still supports From 666436cf541d3ac085e38c6537aaad25148a29f1 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 04:57:59 +0000 Subject: [PATCH 09/45] feat: add revision_token to requirement_versions for optimistic concurrency control - Added `revision_token` column to `requirement_versions` table to manage optimistic concurrency. - Updated database schema documentation to reflect the new column and its constraints. - Enhanced TypeORM entity to include `revisionToken`. - Created migration to add `revision_token` to the existing `requirement_versions` table. - Updated various schemas in the server code to include `revisionToken` in requirement version outputs and mutations. - Modified tests to ensure migrations are correctly registered and executed. --- docs/database-schema.md | 13 +- lib/mcp/server.ts | 645 +++++++++++++++--- lib/typeorm/entities/requirement-version.ts | 10 + scripts/__tests__/db-sqlserver-admin.test.mjs | 12 +- scripts/db-sqlserver-admin.mjs | 6 +- typeorm/migrations/0001_initial_sqlserver.mjs | 3 +- ...002_requirement_version_revision_token.mjs | 34 + 7 files changed, 625 insertions(+), 98 deletions(-) create mode 100644 typeorm/migrations/0002_requirement_version_revision_token.mjs diff --git a/docs/database-schema.md b/docs/database-schema.md index 235c57fdf..7a408049c 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -195,6 +195,7 @@ erDiagram requirement_versions { integer id PK + text revision_token "uniqueidentifier" integer requirement_id FK integer version_number text description @@ -896,13 +897,15 @@ the immutable properties; all mutable content lives in ### `requirement_versions` A **full snapshot** of a requirement at a specific -version. Every edit creates a new version row, enabling -complete audit history. +version. Published edits create a new draft version row; +draft edits update the latest draft in place under a revision-token +precondition. | Column | Type | Description | | -------- | ------ | ------------- | | `id` | integer PK | Auto-increment primary key | +| `revision_token` | uniqueidentifier | Opaque optimistic-concurrency token; changes whenever the version row changes | | `requirement_id` | integer FK → `requirements.id` | Parent requirement | | `version_number` | integer | Monotonically increasing version within the requirement | | `description` | text | The requirement specification text | @@ -922,9 +925,10 @@ complete audit history. | `created_by` | text | User or system that created this version (nullable) | -**Unique constraint:** +**Unique constraints:** `uq_requirement_versions_requirement_id_version_number` -on `(requirement_id, version_number)`. +on `(requirement_id, version_number)`; +`uq_requirement_versions_revision_token` on `revision_token`. **Indexes:** `idx_requirement_versions_requirement_id`. **Lifecycle invariant:** `created_at` < `published_at` @@ -1279,6 +1283,7 @@ its purpose and the table/column(s) it covers. | `uq_requirement_list_column_defaults_sort_order` | `requirement_list_column_defaults` | `sort_order` | Ensures each default list position is assigned to exactly one column | | `uq_requirements_unique_id` | `requirements` | `unique_id` | Ensures each requirement has a distinct human-readable ID | | `uq_requirement_versions_requirement_id_version_number` | `requirement_versions` | `(requirement_id, version_number)` | Ensures version numbers are unique per requirement | +| `uq_requirement_versions_revision_token` | `requirement_versions` | `revision_token` | Ensures each opaque edit token identifies one version row | | `uq_package_responsibility_areas_name_sv` | `package_responsibility_areas` | `name_sv` | Prevents duplicate Swedish responsibility area names | | `uq_package_responsibility_areas_name_en` | `package_responsibility_areas` | `name_en` | Prevents duplicate English responsibility area names | | `uq_owners_email` | `owners` | `email` | Prevents duplicate owner email addresses | diff --git a/lib/mcp/server.ts b/lib/mcp/server.ts index b8507551d..cfa7062fa 100644 --- a/lib/mcp/server.ts +++ b/lib/mcp/server.ts @@ -48,40 +48,167 @@ const PaginationSchema = z }) .strict() +const ResponseFormatSchema = z + .enum(['json', 'markdown']) + .default('markdown') + .describe('Use "json" for machine-readable text, or "markdown" for display.') + +const ResponseLocaleSchema = z + .enum(['en', 'sv']) + .default('en') + .describe('Response language for names and messages.') + const QueryCatalogOutputSchema = z .object({ - catalog: z.string(), - items: z.array(z.record(z.string(), z.unknown())), + catalog: z + .enum([ + 'requirements', + 'areas', + 'categories', + 'types', + 'quality_characteristics', + 'risk_levels', + 'statuses', + 'scenarios', + 'transitions', + ]) + .describe('Catalog that was returned.'), + items: z + .array(z.record(z.string(), z.unknown())) + .describe('Catalog rows. Shape depends on the selected catalog.'), message: z.string(), - pagination: PaginationSchema.nullable(), + pagination: PaginationSchema.nullable().describe( + 'Pagination metadata for catalog "requirements"; null for lookup catalogs.', + ), }) .strict() +const RequirementVersionOutputSchema = z + .record(z.string(), z.unknown()) + .describe( + 'Requirement version object. Includes numeric id and opaque revisionToken. For edit preconditions, fetch requirements_get_requirement with view: "history" and use requirement.versions[0].id as requirement.baseVersionId plus requirement.versions[0].revisionToken as requirement.baseRevisionToken.', + ) + +const RequirementDetailOutputSchema = z + .record(z.string(), z.unknown()) + .describe( + 'Requirement detail object. Its versions array contains selected version objects; with view: "history", requirement.versions[0] is the latest overall version and the edit base.', + ) + const GetRequirementOutputSchema = z .object({ message: z.string(), - requirement: z.record(z.string(), z.unknown()), + requirement: RequirementDetailOutputSchema, requirementResourceUri: z.string(), requirementViewUri: z.string(), - version: z.record(z.string(), z.unknown()).optional(), - versions: z.array(z.record(z.string(), z.unknown())).optional(), + version: RequirementVersionOutputSchema.optional().describe( + 'Requested version object when view is "version". Includes revisionToken.', + ), + versions: z + .array(RequirementVersionOutputSchema) + .optional() + .describe( + 'All version objects when view is "history"; versions[0] is the latest overall version and includes id plus revisionToken for edit base fields.', + ), }) .strict() const ManageRequirementOutputSchema = z .object({ - detail: z.record(z.string(), z.unknown()).optional(), + detail: RequirementDetailOutputSchema.optional().describe( + 'Updated requirement snapshot. On stale edit conflicts, error details.latest contains the latest snapshot with revisionToken.', + ), message: z.string(), operation: z.string(), - result: z.record(z.string(), z.unknown()), + result: z + .record(z.string(), z.unknown()) + .describe( + 'Operation result. Edit results include the updated version id.', + ), }) .strict() const TransitionRequirementOutputSchema = z .object({ - detail: z.record(z.string(), z.unknown()), + detail: RequirementDetailOutputSchema, + message: z.string(), + version: RequirementVersionOutputSchema.describe( + 'Transitioned version object with the newly rotated revisionToken.', + ), + }) + .strict() + +const ListSuggestionsOutputSchema = z + .object({ + counts: z + .object({ + dismissed: z.number(), + pending: z.number(), + resolved: z.number(), + total: z.number(), + }) + .strict(), + message: z.string(), + suggestions: z.array( + z + .object({ + content: z.string(), + createdAt: z.string(), + createdBy: z.string().nullable(), + id: z.number(), + isReviewRequested: z.number(), + requirementId: z.number(), + requirementVersionId: z.number().nullable(), + resolution: z.number().nullable(), + resolutionMotivation: z.string().nullable(), + resolvedAt: z.string().nullable(), + resolvedBy: z.string().nullable(), + updatedAt: z.string().nullable(), + }) + .strict(), + ), + }) + .strict() + +const ManageSuggestionOutputSchema = z + .object({ message: z.string(), - version: z.record(z.string(), z.unknown()), + result: z + .record(z.string(), z.unknown()) + .describe('Created or updated suggestion data, or an operation result.'), + }) + .strict() + +const GeneratedRequirementOutputSchema = z + .object({ + acceptanceCriteria: z.string().nullable().optional(), + categoryId: z.number().optional(), + description: z.string(), + qualityCharacteristicId: z.number().optional(), + rationale: z.string(), + requiresTesting: z.boolean(), + riskLevelId: z.number().optional(), + scenarioIds: z.array(z.number()).optional(), + typeId: z.number(), + verificationMethod: z.string().nullable().optional(), + }) + .strict() + +const GenerateRequirementsOutputSchema = z + .object({ + message: z.string(), + model: z.string(), + requirements: z.array(GeneratedRequirementOutputSchema), + stats: z + .object({ + completionTokens: z.number(), + cost: z.number(), + promptTokens: z.number(), + reasoningTokens: z.number(), + totalTokens: z.number(), + }) + .strict(), + thinking: z.string(), }) .strict() @@ -357,7 +484,12 @@ function renderRequirementHtml( function createQueryCatalogSchema() { return z .object({ - areaIds: z.array(z.number().int().positive()).optional(), + areaIds: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Requirement area IDs. Applies only to catalog "requirements".', + ), catalog: z .enum([ 'requirements', @@ -365,25 +497,126 @@ function createQueryCatalogSchema() { 'categories', 'types', 'quality_characteristics', + 'risk_levels', 'statuses', 'scenarios', 'transitions', ]) - .default('requirements'), - categoryIds: z.array(z.number().int().positive()).optional(), - descriptionSearch: z.string().max(200).optional(), - includeArchived: z.boolean().optional(), - limit: z.number().int().min(1).max(50).default(20), - locale: z.enum(['en', 'sv']).default('en'), - offset: z.number().int().min(0).default(0), - requiresTesting: z.array(z.boolean()).optional(), - responseFormat: z.enum(['json', 'markdown']).default('markdown'), - statuses: z.array(z.number().int().positive()).optional(), - qualityCharacteristicIds: z.array(z.number().int().positive()).optional(), - riskLevelIds: z.array(z.number().int().positive()).optional(), - typeId: z.number().int().positive().optional(), - typeIds: z.array(z.number().int().positive()).optional(), - uniqueIdSearch: z.string().max(100).optional(), + .default('requirements') + .describe( + 'Catalog to return. Use "requirements" for paged requirement search; lookup catalogs ignore requirement filters.', + ), + categoryIds: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Requirement category IDs. Applies only to catalog "requirements".', + ), + descriptionSearch: z + .string() + .max(200) + .optional() + .describe( + 'Case-insensitive substring filter on requirement description. Applies only to catalog "requirements".', + ), + includeArchived: z + .boolean() + .optional() + .describe( + 'Whether archived requirements are included. Applies only to catalog "requirements".', + ), + limit: z + .number() + .int() + .min(1) + .max(50) + .default(20) + .describe('Page size for catalog "requirements"; maximum 50.'), + locale: ResponseLocaleSchema, + normReferenceIds: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Norm reference IDs. Applies only to catalog "requirements".', + ), + offset: z + .number() + .int() + .min(0) + .default(0) + .describe('Zero-based offset for catalog "requirements".'), + qualityCharacteristicIds: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Quality characteristic IDs. Applies only to catalog "requirements".', + ), + requiresTesting: z + .array(z.boolean()) + .optional() + .describe( + 'Filter by testing requirement. Applies only to catalog "requirements".', + ), + responseFormat: ResponseFormatSchema, + riskLevelIds: z + .array(z.number().int().positive()) + .optional() + .describe('Risk level IDs. Applies only to catalog "requirements".'), + sortBy: z + .enum([ + 'uniqueId', + 'description', + 'area', + 'category', + 'type', + 'qualityCharacteristic', + 'riskLevel', + 'status', + 'version', + ]) + .optional() + .describe( + 'Sort field for catalog "requirements". Defaults to uniqueId.', + ), + sortDirection: z + .enum(['asc', 'desc']) + .optional() + .describe( + 'Sort direction for catalog "requirements". Defaults to asc.', + ), + statuses: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Requirement status IDs. Applies only to catalog "requirements".', + ), + typeId: z + .number() + .int() + .positive() + .optional() + .describe( + 'Requirement type ID used only to filter catalog "quality_characteristics".', + ), + typeIds: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Requirement type IDs. Applies only to catalog "requirements".', + ), + uniqueIdSearch: z + .string() + .max(100) + .optional() + .describe( + 'Case-insensitive substring filter on requirement uniqueId. Applies only to catalog "requirements".', + ), + usageScenarioIds: z + .array(z.number().int().positive()) + .optional() + .describe( + 'Usage scenario IDs. Applies only to catalog "requirements".', + ), }) .strict() } @@ -391,64 +624,271 @@ function createQueryCatalogSchema() { function createGetRequirementSchema() { return z .object({ - id: z.number().int().positive().optional(), - locale: z.enum(['en', 'sv']).default('en'), - responseFormat: z.enum(['json', 'markdown']).default('markdown'), - uniqueId: z.string().max(64).optional(), - versionNumber: z.number().int().positive().optional(), - view: z.enum(['detail', 'history', 'version']).default('detail'), + id: z + .number() + .int() + .positive() + .optional() + .describe('Numeric requirement ID. Use either id or uniqueId.'), + locale: ResponseLocaleSchema, + responseFormat: ResponseFormatSchema, + uniqueId: z + .string() + .max(64) + .optional() + .describe('Stable requirement ID, e.g. "REQ-001".'), + versionNumber: z + .number() + .int() + .positive() + .optional() + .describe('Required when view is "version".'), + view: z + .enum(['detail', 'history', 'version']) + .default('detail') + .describe( + 'Use "detail" for the latest published version, "version" with versionNumber for one historical version, or "history" before editing so requirement.versions[0] is the latest overall version.', + ), }) .strict() + .superRefine((val, ctx) => { + if ((val.id == null) === (val.uniqueId == null)) { + ctx.addIssue({ + code: 'custom', + message: 'Provide exactly one of id or uniqueId.', + path: ['id'], + }) + } + if (val.view === 'version' && val.versionNumber == null) { + ctx.addIssue({ + code: 'custom', + message: 'versionNumber is required when view is "version".', + path: ['versionNumber'], + }) + } + }) } const RequirementMutationSchema = z .object({ - acceptanceCriteria: z.string().max(4000).optional(), - areaId: z.number().int().positive().optional(), - categoryId: z.number().int().positive().optional(), - createdBy: z.string().max(200).optional(), - description: z.string().max(4000).optional(), - expectedEditedAt: z.string().max(80).nullable().optional(), - requiresTesting: z.boolean().optional(), - verificationMethod: z.string().max(4000).optional(), - scenarioIds: z.array(z.number().int().positive()).optional(), - normReferenceIds: z.array(z.number().int().positive()).optional(), - qualityCharacteristicId: z.number().int().positive().optional(), - riskLevelId: z.number().int().positive().optional(), - typeId: z.number().int().positive().optional(), + acceptanceCriteria: z + .string() + .max(4000) + .optional() + .describe('Acceptance criteria text for the requirement version.'), + areaId: z + .number() + .int() + .positive() + .optional() + .describe('Requirement area ID. Required when creating.'), + baseRevisionToken: z + .uuid() + .optional() + .describe( + 'Required for operation "edit". First call requirements_get_requirement with view: "history", then copy requirement.versions[0].revisionToken.', + ), + baseVersionId: z + .number() + .int() + .positive() + .optional() + .describe( + 'Required for operation "edit". First call requirements_get_requirement with view: "history", then copy requirement.versions[0].id.', + ), + categoryId: z + .number() + .int() + .positive() + .optional() + .describe('Requirement category ID.'), + createdBy: z + .string() + .max(200) + .optional() + .describe('Optional actor/user label for the created or edited version.'), + description: z + .string() + .max(4000) + .optional() + .describe('Requirement description text. Required for create and edit.'), + requiresTesting: z + .boolean() + .optional() + .describe('Whether the requirement must be verified by test.'), + verificationMethod: z + .string() + .max(4000) + .optional() + .describe( + 'How the requirement should be verified when requiresTesting is true.', + ), + scenarioIds: z + .array(z.number().int().positive()) + .optional() + .describe('Usage scenario IDs linked to the version.'), + normReferenceIds: z + .array(z.number().int().positive()) + .optional() + .describe('Norm reference IDs linked to the version.'), + qualityCharacteristicId: z + .number() + .int() + .positive() + .optional() + .describe('Quality characteristic ID.'), + riskLevelId: z + .number() + .int() + .positive() + .optional() + .describe('Risk level ID.'), + typeId: z + .number() + .int() + .positive() + .optional() + .describe('Requirement type ID.'), }) .strict() function createManageRequirementSchema() { return z .object({ - id: z.number().int().positive().optional(), - locale: z.enum(['en', 'sv']).default('en'), - operation: z.enum([ - 'archive', - 'create', - 'delete_draft', - 'edit', - 'restore_version', - ]), - requirement: RequirementMutationSchema.optional(), - responseFormat: z.enum(['json', 'markdown']).default('markdown'), - uniqueId: z.string().max(64).optional(), - versionNumber: z.number().int().positive().optional(), + id: z + .number() + .int() + .positive() + .optional() + .describe( + 'Numeric requirement ID. Required for non-create operations when uniqueId is not provided.', + ), + locale: ResponseLocaleSchema, + operation: z + .enum(['archive', 'create', 'delete_draft', 'edit', 'restore_version']) + .describe( + 'Operation to perform. Create has no existing requirement ID; all other operations require id or uniqueId.', + ), + requirement: RequirementMutationSchema.optional().describe( + 'Requirement fields for create/edit. For create, pass at least requirement.areaId and requirement.description; optional fields include acceptanceCriteria, typeId, categoryId, qualityCharacteristicId, riskLevelId, requiresTesting, verificationMethod, scenarioIds, normReferenceIds, and createdBy. For edit, first call requirements_get_requirement with view: "history" and copy requirement.versions[0].id to baseVersionId plus requirement.versions[0].revisionToken to baseRevisionToken.', + ), + responseFormat: ResponseFormatSchema, + uniqueId: z + .string() + .max(64) + .optional() + .describe( + 'Stable requirement ID, e.g. "REQ-001". Required for non-create operations when id is not provided.', + ), + versionNumber: z + .number() + .int() + .positive() + .optional() + .describe('Required for operation "restore_version".'), }) .strict() + .superRefine((val, ctx) => { + if ( + val.operation !== 'create' && + (val.id == null) === (val.uniqueId == null) + ) { + ctx.addIssue({ + code: 'custom', + message: 'Provide exactly one of id or uniqueId for this operation.', + path: ['id'], + }) + } + + if (val.operation === 'create') { + if (!val.requirement?.areaId) { + ctx.addIssue({ + code: 'custom', + message: 'requirement.areaId is required for operation "create".', + path: ['requirement', 'areaId'], + }) + } + if (!val.requirement?.description) { + ctx.addIssue({ + code: 'custom', + message: + 'requirement.description is required for operation "create".', + path: ['requirement', 'description'], + }) + } + } + + if (val.operation === 'edit') { + if (!val.requirement?.description) { + ctx.addIssue({ + code: 'custom', + message: + 'requirement.description is required for operation "edit".', + path: ['requirement', 'description'], + }) + } + if (val.requirement?.baseVersionId == null) { + ctx.addIssue({ + code: 'custom', + message: + 'requirement.baseVersionId is required for operation "edit".', + path: ['requirement', 'baseVersionId'], + }) + } + if (val.requirement?.baseRevisionToken == null) { + ctx.addIssue({ + code: 'custom', + message: + 'requirement.baseRevisionToken is required for operation "edit".', + path: ['requirement', 'baseRevisionToken'], + }) + } + } + + if (val.operation === 'restore_version' && val.versionNumber == null) { + ctx.addIssue({ + code: 'custom', + message: 'versionNumber is required for operation "restore_version".', + path: ['versionNumber'], + }) + } + }) } function createTransitionRequirementSchema() { return z .object({ - id: z.number().int().positive().optional(), - locale: z.enum(['en', 'sv']).default('en'), - responseFormat: z.enum(['json', 'markdown']).default('markdown'), - toStatusId: z.number().int().positive(), - uniqueId: z.string().max(64).optional(), + id: z + .number() + .int() + .positive() + .optional() + .describe('Numeric requirement ID. Use either id or uniqueId.'), + locale: ResponseLocaleSchema, + responseFormat: ResponseFormatSchema, + toStatusId: z + .number() + .int() + .positive() + .describe( + 'Target requirement status ID. Use requirements_query_catalog with catalog "transitions" or "statuses" before choosing this value.', + ), + uniqueId: z + .string() + .max(64) + .optional() + .describe('Stable requirement ID, e.g. "REQ-001".'), }) .strict() + .superRefine((val, ctx) => { + if ((val.id == null) === (val.uniqueId == null)) { + ctx.addIssue({ + code: 'custom', + message: 'Provide exactly one of id or uniqueId.', + path: ['id'], + }) + } + }) } function toCatalogInput( @@ -462,15 +902,19 @@ function toCatalogInput( includeArchived: input.includeArchived, limit: input.limit, locale: toResponseLocale(input.locale), + normReferenceIds: input.normReferenceIds, offset: input.offset, requiresTesting: input.requiresTesting, responseFormat: toResponseFormat(input.responseFormat), + sortBy: input.sortBy, + sortDirection: input.sortDirection, statuses: input.statuses, qualityCharacteristicIds: input.qualityCharacteristicIds, riskLevelIds: input.riskLevelIds, typeId: input.typeId, typeIds: input.typeIds, uniqueIdSearch: input.uniqueIdSearch, + usageScenarioIds: input.usageScenarioIds, } } @@ -668,7 +1112,7 @@ export function createKravhanteringMcpServer( readOnlyHint: true, }, description: - 'List or search requirements and supporting lookup catalogs such as areas, categories, types, scenarios, statuses, and transitions.', + 'List/search paginated requirements or fetch lookup catalogs: areas, categories, types, quality_characteristics, risk_levels, statuses, scenarios, and transitions. Requirement filters, sorting, limit, and offset apply only when catalog is "requirements".', inputSchema: createQueryCatalogSchema(), outputSchema: QueryCatalogOutputSchema, title: 'Query Requirements Catalog', @@ -704,7 +1148,7 @@ export function createKravhanteringMcpServer( readOnlyHint: true, }, description: - 'Fetch the current requirement detail, a specific version, or the full version history by stable requirement ID.', + 'Fetch the current requirement detail, a specific version, or the full version history by stable requirement ID. Before editing, call with view: "history" and use requirement.versions[0].id plus requirement.versions[0].revisionToken as the edit base.', inputSchema: createGetRequirementSchema(), outputSchema: GetRequirementOutputSchema, title: 'Get Requirement', @@ -747,7 +1191,7 @@ export function createKravhanteringMcpServer( readOnlyHint: false, }, description: - 'Create, edit, archive, delete a latest draft, or restore a historical requirement version.', + 'Create, edit, archive, delete a latest draft, or restore a historical requirement version. For operation "create", pass requirement.areaId and requirement.description, plus optional classification/verification fields. For operation "edit", first call requirements_get_requirement with view: "history", then pass requirement.versions[0].id as requirement.baseVersionId and requirement.versions[0].revisionToken as requirement.baseRevisionToken.', inputSchema: createManageRequirementSchema(), outputSchema: ManageRequirementOutputSchema, title: 'Manage Requirement', @@ -821,7 +1265,7 @@ export function createKravhanteringMcpServer( readOnlyHint: false, }, description: - 'Transition a requirement version through the lifecycle states defined in requirement_status_transitions.', + 'Transition a requirement version through the lifecycle states defined in requirement_status_transitions. A transition rotates the version revisionToken; refetch with requirements_get_requirement before a later edit.', inputSchema: createTransitionRequirementSchema(), outputSchema: TransitionRequirementOutputSchema, title: 'Transition Requirement', @@ -1184,16 +1628,17 @@ export function createKravhanteringMcpServer( 'List improvement suggestions for a specific requirement. Identify the requirement by numeric requirementId or by uniqueId (e.g. "REQ-001").', inputSchema: z .object({ - locale: z.enum(['en', 'sv']).default('en'), + locale: ResponseLocaleSchema, requirementId: z .number() .int() .positive() .optional() .describe('Numeric ID of the requirement.'), - responseFormat: z.enum(['json', 'markdown']).default('markdown'), + responseFormat: ResponseFormatSchema, uniqueId: z .string() + .max(64) .optional() .describe('Unique requirement ID, e.g. "REQ-001".'), }) @@ -1206,6 +1651,7 @@ export function createKravhanteringMcpServer( }) } }), + outputSchema: ListSuggestionsOutputSchema, title: 'List Improvement Suggestions for Requirement', }, async input => { @@ -1247,10 +1693,12 @@ export function createKravhanteringMcpServer( .object({ content: z .string() + .max(4000) .optional() .describe('Suggestion text content. Required for create and edit.'), createdBy: z .string() + .max(200) .optional() .describe('Who created the suggestion.'), suggestionId: z @@ -1261,16 +1709,18 @@ export function createKravhanteringMcpServer( .describe( 'Numeric suggestion ID. Required for all operations except create.', ), - locale: z.enum(['en', 'sv']).default('en'), - operation: z.enum([ - 'create', - 'delete', - 'dismiss', - 'edit', - 'request_review', - 'resolve', - 'revert_to_draft', - ]), + locale: ResponseLocaleSchema, + operation: z + .enum([ + 'create', + 'delete', + 'dismiss', + 'edit', + 'request_review', + 'resolve', + 'revert_to_draft', + ]) + .describe('Suggestion operation to perform.'), requirementId: z .number() .int() @@ -1285,13 +1735,17 @@ export function createKravhanteringMcpServer( .describe('Optional version ID to link the suggestion to.'), resolutionMotivation: z .string() + .max(4000) .optional() - .describe('Motivation text for resolve/dismiss.'), + .describe('Motivation text. Required for resolve and dismiss.'), resolvedBy: z .string() + .max(200) .optional() - .describe('Who resolved/dismissed the suggestion.'), - responseFormat: z.enum(['json', 'markdown']).default('markdown'), + .describe( + 'Who resolved/dismissed the suggestion. Required for resolve/dismiss when the request actor has no ID.', + ), + responseFormat: ResponseFormatSchema, }) .strict() .superRefine((data, ctx) => { @@ -1299,14 +1753,14 @@ export function createKravhanteringMcpServer( case 'create': if (!data.requirementId) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: 'requirementId is required for the create operation', path: ['requirementId'], }) } if (!data.content) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: 'content is required for the create operation', path: ['content'], }) @@ -1315,14 +1769,14 @@ export function createKravhanteringMcpServer( case 'edit': if (!data.suggestionId) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: 'suggestionId is required for the edit operation', path: ['suggestionId'], }) } if (!data.content) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: 'content is required for the edit operation', path: ['content'], }) @@ -1332,18 +1786,25 @@ export function createKravhanteringMcpServer( case 'dismiss': if (!data.suggestionId) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: `suggestionId is required for the ${data.operation} operation`, path: ['suggestionId'], }) } + if (!data.resolutionMotivation) { + ctx.addIssue({ + code: 'custom', + message: `resolutionMotivation is required for the ${data.operation} operation`, + path: ['resolutionMotivation'], + }) + } break case 'delete': case 'request_review': case 'revert_to_draft': if (!data.suggestionId) { ctx.addIssue({ - code: z.ZodIssueCode.custom, + code: 'custom', message: `suggestionId is required for the ${data.operation} operation`, path: ['suggestionId'], }) @@ -1351,6 +1812,7 @@ export function createKravhanteringMcpServer( break } }), + outputSchema: ManageSuggestionOutputSchema, title: 'Manage Improvement Suggestion', }, async input => { @@ -1397,7 +1859,7 @@ export function createKravhanteringMcpServer( 'Generate system requirements using AI (OpenRouter) based on a topic. ' + 'Returns generated requirements with thinking trace. ' + 'To create the generated requirements, call requirements_manage_requirement ' + - 'with operation "create" for each requirement, setting requirement.areaId ' + + 'with operation "create" for each requirement, using the generated fields as the requirement object and setting requirement.areaId ' + "to the areaId provided in this tool's input.", inputSchema: z .object({ @@ -1411,7 +1873,7 @@ export function createKravhanteringMcpServer( ), customInstruction: z .string() - .max(4000) + .max(5000) .optional() .describe( 'Custom instruction to override the default generation prompt', @@ -1430,12 +1892,13 @@ export function createKravhanteringMcpServer( topic: z .string() .min(1) - .max(4000) + .max(1000) .describe( 'The topic or system context to generate requirements for', ), }) .strict(), + outputSchema: GenerateRequirementsOutputSchema, title: 'Generate Requirements (AI)', }, async input => { diff --git a/lib/typeorm/entities/requirement-version.ts b/lib/typeorm/entities/requirement-version.ts index d196b1a7d..4a5305687 100644 --- a/lib/typeorm/entities/requirement-version.ts +++ b/lib/typeorm/entities/requirement-version.ts @@ -22,6 +22,7 @@ export interface RequirementVersionEntity { requirementCategory: RequirementCategoryEntity | null requirementStatus: RequirementStatusEntity requirementType: RequirementTypeEntity | null + revisionToken: string riskLevel: RiskLevelEntity | null verificationMethod: string | null versionNumber: number @@ -38,6 +39,11 @@ export const requirementVersionEntity = type: 'int', generated: 'increment', }, + revisionToken: { + name: 'revision_token', + type: 'uniqueidentifier', + default: () => 'NEWID()', + }, versionNumber: { name: 'version_number', type: 'int' }, description: { name: 'description', type: 'nvarchar', length: 'MAX' }, acceptanceCriteria: { @@ -78,6 +84,10 @@ export const requirementVersionEntity = name: 'uq_requirement_versions_requirement_id_version_number', columns: ['requirement', 'versionNumber'], }, + { + name: 'uq_requirement_versions_revision_token', + columns: ['revisionToken'], + }, ], indices: [ { diff --git a/scripts/__tests__/db-sqlserver-admin.test.mjs b/scripts/__tests__/db-sqlserver-admin.test.mjs index 47b60cffc..1b11020da 100644 --- a/scripts/__tests__/db-sqlserver-admin.test.mjs +++ b/scripts/__tests__/db-sqlserver-admin.test.mjs @@ -300,11 +300,15 @@ describe('db-sqlserver-admin.mjs', () => { }) }) - it('runs the initial TypeORM migration through an injected DataSource', async () => { + it('runs registered TypeORM migrations through an injected DataSource', async () => { + let dataSourceOptions const destroy = vi.fn(async () => undefined) const initialize = vi.fn(async () => undefined) const runMigrations = vi.fn(async () => [{ name: 'InitialMigration' }]) class FakeDataSource { + constructor(options) { + dataSourceOptions = options + } destroy = destroy initialize = initialize runMigrations = runMigrations @@ -316,6 +320,12 @@ describe('db-sqlserver-admin.mjs', () => { ) expect(initialize).toHaveBeenCalled() + expect( + dataSourceOptions.migrations.map(migration => migration.name), + ).toEqual([ + 'InitialSqlServerSchema1713720000000', + 'RequirementVersionRevisionToken1713800000000', + ]) expect(runMigrations).toHaveBeenCalled() expect(destroy).toHaveBeenCalled() expect(result).toEqual({ diff --git a/scripts/db-sqlserver-admin.mjs b/scripts/db-sqlserver-admin.mjs index 43cd1a1e5..f46ee16e8 100644 --- a/scripts/db-sqlserver-admin.mjs +++ b/scripts/db-sqlserver-admin.mjs @@ -5,6 +5,7 @@ import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { DataSource } from 'typeorm' import { InitialSqlServerSchema1713720000000 } from '../typeorm/migrations/0001_initial_sqlserver.mjs' +import { RequirementVersionRevisionToken1713800000000 } from '../typeorm/migrations/0002_requirement_version_revision_token.mjs' import { seedDatabase } from '../typeorm/seed.mjs' export const DEFAULT_BROWSE_CONNECTION_NAME = @@ -267,7 +268,10 @@ function buildMigrationDataSourceOptions(connectionString, env = process.env) { return { connectionTimeout: parsed.connectionTimeout, logging: false, - migrations: [InitialSqlServerSchema1713720000000], + migrations: [ + InitialSqlServerSchema1713720000000, + RequirementVersionRevisionToken1713800000000, + ], options: { enableArithAbort: true, encrypt: parsed.encrypt, diff --git a/typeorm/migrations/0001_initial_sqlserver.mjs b/typeorm/migrations/0001_initial_sqlserver.mjs index fe3c61f31..3d6ea8f24 100644 --- a/typeorm/migrations/0001_initial_sqlserver.mjs +++ b/typeorm/migrations/0001_initial_sqlserver.mjs @@ -19,7 +19,7 @@ const UP_STATEMENTS = [ 'CREATE TABLE [package_local_requirements] (\n [id] int IDENTITY(1,1) NOT NULL,\n [package_id] int NOT NULL,\n [unique_id] nvarchar(450) NOT NULL,\n [sequence_number] int NOT NULL,\n [requirement_area_id] int NULL,\n [description] nvarchar(max) NOT NULL,\n [acceptance_criteria] nvarchar(max) NULL,\n [requirement_category_id] int NULL,\n [requirement_type_id] int NULL,\n [quality_characteristic_id] int NULL,\n [risk_level_id] int NULL,\n [is_testing_required] bit NOT NULL DEFAULT (0),\n [verification_method] nvarchar(max) NULL,\n [needs_reference_id] int NULL,\n [package_item_status_id] int NULL,\n [note] nvarchar(max) NULL,\n [status_updated_at] datetime2(3) NULL,\n [created_at] datetime2(3) NOT NULL,\n [updated_at] datetime2(3) NOT NULL,\n CONSTRAINT [pk_package_local_requirements] PRIMARY KEY ([id])\n);', 'CREATE TABLE [package_local_requirement_deviations] (\n [id] int IDENTITY(1,1) NOT NULL,\n [package_local_requirement_id] int NOT NULL,\n [motivation] nvarchar(max) NOT NULL,\n [is_review_requested] bit NOT NULL DEFAULT (0),\n [decision] int NULL,\n [decision_motivation] nvarchar(max) NULL,\n [decided_by] nvarchar(max) NULL,\n [decided_at] datetime2(3) NULL,\n [created_by] nvarchar(max) NULL,\n [created_at] datetime2(3) NOT NULL,\n [updated_at] datetime2(3) NULL,\n CONSTRAINT [pk_package_local_requirement_deviations] PRIMARY KEY ([id])\n);', 'CREATE TABLE [package_local_requirement_norm_references] (\n [package_local_requirement_id] int NOT NULL,\n [norm_reference_id] int NOT NULL,\n CONSTRAINT [pk_package_local_requirement_norm_references] PRIMARY KEY ([package_local_requirement_id], [norm_reference_id])\n);', - 'CREATE TABLE [requirement_versions] (\n [id] int IDENTITY(1,1) NOT NULL,\n [requirement_id] int NOT NULL,\n [version_number] int NOT NULL,\n [description] nvarchar(max) NOT NULL,\n [acceptance_criteria] nvarchar(max) NULL,\n [requirement_category_id] int NULL,\n [requirement_type_id] int NULL,\n [quality_characteristic_id] int NULL,\n [requirement_status_id] int NOT NULL,\n [is_testing_required] bit NOT NULL DEFAULT (0),\n [verification_method] nvarchar(max) NULL,\n [created_at] datetime2(3) NOT NULL,\n [edited_at] datetime2(3) NULL,\n [published_at] datetime2(3) NULL,\n [archived_at] datetime2(3) NULL,\n [created_by] nvarchar(max) NULL,\n [archive_initiated_at] datetime2(3) NULL,\n [risk_level_id] int NULL,\n CONSTRAINT [pk_requirement_versions] PRIMARY KEY ([id])\n);', + 'CREATE TABLE [requirement_versions] (\n [id] int IDENTITY(1,1) NOT NULL,\n [revision_token] uniqueidentifier NOT NULL CONSTRAINT [df_requirement_versions_revision_token] DEFAULT NEWID(),\n [requirement_id] int NOT NULL,\n [version_number] int NOT NULL,\n [description] nvarchar(max) NOT NULL,\n [acceptance_criteria] nvarchar(max) NULL,\n [requirement_category_id] int NULL,\n [requirement_type_id] int NULL,\n [quality_characteristic_id] int NULL,\n [requirement_status_id] int NOT NULL,\n [is_testing_required] bit NOT NULL DEFAULT (0),\n [verification_method] nvarchar(max) NULL,\n [created_at] datetime2(3) NOT NULL,\n [edited_at] datetime2(3) NULL,\n [published_at] datetime2(3) NULL,\n [archived_at] datetime2(3) NULL,\n [created_by] nvarchar(max) NULL,\n [archive_initiated_at] datetime2(3) NULL,\n [risk_level_id] int NULL,\n CONSTRAINT [pk_requirement_versions] PRIMARY KEY ([id])\n);', 'CREATE TABLE [improvement_suggestions] (\n [id] int IDENTITY(1,1) NOT NULL,\n [requirement_id] int NOT NULL,\n [requirement_version_id] int NULL,\n [content] nvarchar(max) NOT NULL,\n [is_review_requested] bit NOT NULL DEFAULT (0),\n [resolution] int NULL,\n [resolution_motivation] nvarchar(max) NULL,\n [resolved_by] nvarchar(max) NULL,\n [resolved_at] datetime2(3) NULL,\n [created_by] nvarchar(max) NULL,\n [created_at] datetime2(3) NOT NULL,\n [updated_at] datetime2(3) NULL,\n [review_requested_at] datetime2(3) NULL,\n CONSTRAINT [pk_improvement_suggestions] PRIMARY KEY ([id])\n);', 'CREATE TABLE [requirement_package_items] (\n [id] int IDENTITY(1,1) NOT NULL,\n [requirement_package_id] int NOT NULL,\n [requirement_id] int NOT NULL,\n [requirement_version_id] int NOT NULL,\n [needs_reference_id] int NULL,\n [unused_1] nvarchar(max) NULL,\n [created_at] datetime2(3) NOT NULL,\n [package_item_status_id] int NULL,\n [note] nvarchar(max) NULL,\n [status_updated_at] datetime2(3) NULL,\n CONSTRAINT [pk_requirement_package_items] PRIMARY KEY ([id])\n);', 'CREATE TABLE [deviations] (\n [id] int IDENTITY(1,1) NOT NULL,\n [package_item_id] int NOT NULL,\n [motivation] nvarchar(max) NOT NULL,\n [decision] int NULL,\n [decision_motivation] nvarchar(max) NULL,\n [decided_by] nvarchar(max) NULL,\n [decided_at] datetime2(3) NULL,\n [created_by] nvarchar(max) NULL,\n [created_at] datetime2(3) NOT NULL,\n [updated_at] datetime2(3) NULL,\n [is_review_requested] bit NOT NULL DEFAULT (0),\n CONSTRAINT [pk_deviations] PRIMARY KEY ([id])\n);', @@ -66,6 +66,7 @@ const UP_STATEMENTS = [ 'CREATE INDEX [idx_package_local_requirement_deviations_package_local_requirement_id] ON [package_local_requirement_deviations] ([package_local_requirement_id]);', 'CREATE INDEX [idx_package_local_requirement_norm_references_norm_reference_id] ON [package_local_requirement_norm_references] ([norm_reference_id]);', 'CREATE UNIQUE INDEX [uq_requirement_versions_requirement_id_version_number] ON [requirement_versions] ([requirement_id], [version_number]);', + 'CREATE UNIQUE INDEX [uq_requirement_versions_revision_token] ON [requirement_versions] ([revision_token]);', 'CREATE INDEX [idx_requirement_versions_requirement_id] ON [requirement_versions] ([requirement_id]);', 'CREATE INDEX [idx_improvement_suggestions_requirement_version_id] ON [improvement_suggestions] ([requirement_version_id]);', 'CREATE INDEX [idx_improvement_suggestions_requirement_id] ON [improvement_suggestions] ([requirement_id]);', diff --git a/typeorm/migrations/0002_requirement_version_revision_token.mjs b/typeorm/migrations/0002_requirement_version_revision_token.mjs new file mode 100644 index 000000000..7f46fde43 --- /dev/null +++ b/typeorm/migrations/0002_requirement_version_revision_token.mjs @@ -0,0 +1,34 @@ +const UP_STATEMENTS = [ + "IF COL_LENGTH(N'dbo.requirement_versions', N'revision_token') IS NULL\nBEGIN\n ALTER TABLE [requirement_versions] ADD [revision_token] uniqueidentifier NULL;\n UPDATE [requirement_versions] SET [revision_token] = NEWID() WHERE [revision_token] IS NULL;\n ALTER TABLE [requirement_versions] ALTER COLUMN [revision_token] uniqueidentifier NOT NULL;\n ALTER TABLE [requirement_versions] ADD CONSTRAINT [df_requirement_versions_revision_token] DEFAULT NEWID() FOR [revision_token];\n CREATE UNIQUE INDEX [uq_requirement_versions_revision_token] ON [requirement_versions] ([revision_token]);\nEND", +] + +const DOWN_STATEMENTS = [ + "IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_requirement_versions_revision_token' AND object_id = OBJECT_ID(N'dbo.requirement_versions')) DROP INDEX [uq_requirement_versions_revision_token] ON [requirement_versions];", + "IF EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = N'df_requirement_versions_revision_token' AND parent_object_id = OBJECT_ID(N'dbo.requirement_versions')) ALTER TABLE [requirement_versions] DROP CONSTRAINT [df_requirement_versions_revision_token];", + "IF COL_LENGTH(N'dbo.requirement_versions', N'revision_token') IS NOT NULL ALTER TABLE [requirement_versions] DROP COLUMN [revision_token];", +] + +export class RequirementVersionRevisionToken1713800000000 { + name = 'RequirementVersionRevisionToken1713800000000' + async up(queryRunner) { + for (const sql of UP_STATEMENTS) { + try { + await queryRunner.query(sql) + } catch (err) { + err.message = `${err.message}\n--- failing statement:\n${sql}` + throw err + } + } + } + async down(queryRunner) { + for (const sql of DOWN_STATEMENTS) { + try { + await queryRunner.query(sql) + } catch (err) { + err.message = `${err.message}\n--- failing statement:\n${sql}` + throw err + } + } + } +} +export default RequirementVersionRevisionToken1713800000000 From 5aa30f72f648ac69b133732705ec446dee20bfd4 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:00:12 +0000 Subject: [PATCH 10/45] fix: add MCP Tool Contract upkeep instructions for callable behavior changes --- .../mcp-tool-contract.instructions.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/instructions/mcp-tool-contract.instructions.md diff --git a/.github/instructions/mcp-tool-contract.instructions.md b/.github/instructions/mcp-tool-contract.instructions.md new file mode 100644 index 000000000..3200a35a1 --- /dev/null +++ b/.github/instructions/mcp-tool-contract.instructions.md @@ -0,0 +1,20 @@ +--- +applyTo: "{lib/mcp/**/*.ts,lib/requirements/**/*.ts,lib/dal/**/*.ts,app/api/**/*.ts,docs/mcp-server-*.md,tests/unit/mcp-http.test.ts}" +--- + +# MCP Tool Contract Upkeep + +## When Changing Callable Behavior + +- If logic changes what an MCP client must send, can receive, or should do + next, update `lib/mcp/server.ts` in the same change. +- Keep tool `description`, `inputSchema`, `outputSchema`, and field + `.describe(...)` text aligned with the handler/service behavior. +- State prerequisite tool calls and exact source/destination fields for values + clients must echo, such as `requirements_get_requirement` + `requirement.versions[0].id` to `requirement.baseVersionId`. +- Do not rely on user docs alone to teach MCP clients how to call a tool. +- Update `docs/mcp-server-user-guide.md`, + `docs/mcp-server-contributor-guide.md`, and `tests/unit/mcp-http.test.ts`. +- For outward-facing MCP invariants, update the quality siblings described in + `.github/instructions/quality-spec.instructions.md`. From ab43b384075c344853513c535f889d54f48a0563 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:00:33 +0000 Subject: [PATCH 11/45] fix: replace expectedEditedAt with baseRevisionToken and baseVersionId for requirement editing --- .../[id]/edit/edit-requirement-client.tsx | 14 ++- .../requirements/requirements-client.tsx | 1 + app/api/requirements/[id]/route.ts | 12 +- cspell.jsonc | 2 + lib/dal/requirements.ts | 116 +++++++++++------- lib/requirements/list-view.ts | 1 + lib/requirements/service.ts | 15 ++- lib/requirements/types.ts | 1 + 8 files changed, 104 insertions(+), 58 deletions(-) diff --git a/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx b/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx index 0ee643234..4b9047c14 100644 --- a/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx +++ b/app/[locale]/requirements/[id]/edit/edit-requirement-client.tsx @@ -46,7 +46,10 @@ export default function EditRequirementClient({ number[] >([]) const [initialScenarioIds, setInitialScenarioIds] = useState([]) - const [expectedEditedAt, setExpectedEditedAt] = useState(null) + const [baseRevisionToken, setBaseRevisionToken] = useState( + null, + ) + const [baseVersionId, setBaseVersionId] = useState(null) const [uniqueId, setUniqueId] = useState('') const [isPublished, setIsPublished] = useState(false) const [loading, setLoading] = useState(true) @@ -54,7 +57,8 @@ export default function EditRequirementClient({ const fetchData = useCallback(async () => { setFetchError(null) - setExpectedEditedAt(null) + setBaseRevisionToken(null) + setBaseVersionId(null) setIsPublished(false) setLoading(true) @@ -86,7 +90,8 @@ export default function EditRequirementClient({ return } setIsPublished(latest.status === STATUS_PUBLISHED) - setExpectedEditedAt(latest.editedAt) + setBaseRevisionToken(latest.revisionToken) + setBaseVersionId(latest.id) setInitialData({ areaId: data.area?.id != null ? String(data.area.id) : '', categoryId: @@ -172,7 +177,8 @@ export default function EditRequirementClient({ )}
Number(value)) diff --git a/cspell.jsonc b/cspell.jsonc index c574f4238..0256fd081 100644 --- a/cspell.jsonc +++ b/cspell.jsonc @@ -13,6 +13,7 @@ "DICOM", "focusable", "healthcheck", + "HOLDLOCK", "Kravpaketansvarig", "Kravpaketmedförfattare", "läsbehörighet", @@ -35,6 +36,7 @@ "tokenroller", "tokentyp", "Uppfyllnad", + "UPDLOCK", "upserted", "urllib", "urlopen", diff --git a/lib/dal/requirements.ts b/lib/dal/requirements.ts index 21b095ce2..b0b15592f 100644 --- a/lib/dal/requirements.ts +++ b/lib/dal/requirements.ts @@ -286,6 +286,7 @@ export async function listRequirements( CAST(requirement.is_archived AS int) AS isArchived, requirement.created_at AS createdAt, version.id AS versionId, + version.revision_token AS revisionToken, version.version_number AS versionNumber, version.description AS description, version.acceptance_criteria AS acceptanceCriteria, @@ -358,6 +359,7 @@ export async function listRequirements( isArchived: toBool(row.isArchived), createdAt: toIso(row.createdAt) ?? '', versionId: Number(row.versionId), + revisionToken: String(row.revisionToken ?? '').toLowerCase(), versionNumber: Number(row.versionNumber), description: String(row.description ?? ''), acceptanceCriteria: @@ -474,9 +476,10 @@ async function getNextVersionNumberSqlServer( interface RequirementMutationData { acceptanceCriteria?: string + baseRevisionToken?: string | null + baseVersionId?: number | null createdBy?: string description: string - expectedEditedAt?: string | null normReferenceIds?: number[] qualityCharacteristicId?: number requirementAreaId: number @@ -536,6 +539,7 @@ interface VersionInsertedRow { requirementId: number requirementTypeId: number | null requiresTesting: boolean + revisionToken: string riskLevelId: number | null statusId: number verificationMethod: string | null @@ -571,6 +575,7 @@ function mapVersion(row: Record): VersionInsertedRow { row.verificationMethod == null ? null : String(row.verificationMethod), createdAt: toIso(row.createdAt) ?? '', editedAt: toIso(row.editedAt), + revisionToken: String(row.revisionToken ?? '').toLowerCase(), publishedAt: toIso(row.publishedAt), archivedAt: toIso(row.archivedAt), archiveInitiatedAt: toIso(row.archiveInitiatedAt), @@ -591,6 +596,7 @@ const REQUIREMENT_OUTPUT = ` const VERSION_OUTPUT = ` OUTPUT INSERTED.id AS id, + INSERTED.revision_token AS revisionToken, INSERTED.requirement_id AS requirementId, INSERTED.version_number AS versionNumber, INSERTED.description AS description, @@ -688,6 +694,7 @@ interface VersionLite { requirementCategoryId: number | null requirementTypeId: number | null requiresTesting: boolean + revisionToken: string riskLevelId: number | null statusId: number verificationMethod: string | null @@ -696,10 +703,13 @@ interface VersionLite { async function getLatestVersionLite( tx: SqlServerTxExecutor, requirementId: number, + options: { lockForUpdate?: boolean } = {}, ): Promise { + const lockHint = options.lockForUpdate ? ' WITH (UPDLOCK, HOLDLOCK)' : '' const rows = (await tx.query( `SELECT TOP (1) id, + revision_token AS revisionToken, requirement_status_id AS statusId, archive_initiated_at AS archiveInitiatedAt, description, @@ -712,7 +722,7 @@ async function getLatestVersionLite( CAST(is_testing_required AS int) AS requiresTesting, verification_method AS verificationMethod, created_by AS createdBy - FROM requirement_versions + FROM requirement_versions${lockHint} WHERE requirement_id = @0 ORDER BY version_number DESC`, [requirementId], @@ -721,6 +731,7 @@ async function getLatestVersionLite( const row = rows[0] return { id: Number(row.id), + revisionToken: String(row.revisionToken ?? '').toLowerCase(), statusId: Number(row.statusId), archiveInitiatedAt: toIso(row.archiveInitiatedAt), description: String(row.description ?? ''), @@ -738,39 +749,45 @@ async function getLatestVersionLite( } } -function normalizeExpectedEditedAt(value: string | null | undefined) { - if (value === undefined) { +const GUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +function normalizeBaseVersionId(value: number | null | undefined) { + if (value == null || !Number.isInteger(value) || value <= 0) { + throw validationError('Edit operation requires requirement.baseVersionId', { + reason: 'missing_edit_precondition', + }) + } + return value +} + +function normalizeBaseRevisionToken(value: string | null | undefined) { + if (value == null || value.trim() === '') { throw validationError( - 'Edit operation requires requirement.expectedEditedAt', + 'Edit operation requires requirement.baseRevisionToken', + { reason: 'missing_edit_precondition' }, ) } - if (value === null) return null - const date = new Date(value) - if (Number.isNaN(date.getTime())) { + const normalized = value.trim().toLowerCase() + if (!GUID_PATTERN.test(normalized)) { throw validationError( - 'Edit operation requires a valid requirement.expectedEditedAt timestamp', + 'Edit operation requires a valid requirement.baseRevisionToken GUID', + { reason: 'invalid_edit_precondition' }, ) } - return date.toISOString() -} - -function hasMatchingEditedAt(current: string | null, expected: string | null) { - if (current == null || expected == null) { - return current == null && expected == null - } - return new Date(current).getTime() === new Date(expected).getTime() + return normalized } function staleRequirementEditError( requirementId: number, - expectedEditedAt: string | null, - latestEditedAt: string | null, + baseVersionId: number, + latestVersionId: number, ) { return conflictError( 'This requirement was updated after you started editing. Refresh to review the latest version before saving.', { - expectedEditedAt, - latestEditedAt, + baseVersionId, + latestVersionId, reason: 'stale_requirement_edit', requirementId, }, @@ -786,7 +803,8 @@ export async function editRequirement( ): Promise { const scenarioIds = uniqueIds(data.scenarioIds) const normRefIds = uniqueIds(data.normReferenceIds) - const expectedEditedAt = normalizeExpectedEditedAt(data.expectedEditedAt) + const baseVersionId = normalizeBaseVersionId(data.baseVersionId) + const baseRevisionToken = normalizeBaseRevisionToken(data.baseRevisionToken) const now = new Date() const verificationMethod = data.requiresTesting ? (data.verificationMethod ?? null) @@ -794,12 +812,14 @@ export async function editRequirement( let result!: VersionInsertedRow - await db.transaction(async manager => { + await db.transaction('SERIALIZABLE', async manager => { const tx: SqlServerTxExecutor = { query: (sql, params) => manager.query(sql, params), } - const current = await getLatestVersionLite(tx, requirementId) + const current = await getLatestVersionLite(tx, requirementId, { + lockForUpdate: true, + }) if (!current) { throw notFoundError('No version found for requirement') } @@ -811,12 +831,11 @@ export async function editRequirement( 'Cannot edit an archived requirement — restore it first', ) } - if (!hasMatchingEditedAt(current.editedAt, expectedEditedAt)) { - throw staleRequirementEditError( - requirementId, - data.expectedEditedAt ?? null, - current.editedAt, - ) + if ( + current.id !== baseVersionId || + current.revisionToken !== baseRevisionToken + ) { + throw staleRequirementEditError(requirementId, baseVersionId, current.id) } if (current.statusId === STATUS_DRAFT) { @@ -830,13 +849,11 @@ export async function editRequirement( risk_level_id = @5, is_testing_required = @6, verification_method = @7, - edited_at = @8 + edited_at = @8, + revision_token = NEWID() ${VERSION_OUTPUT} WHERE id = @9 - AND ( - (@10 IS NULL AND edited_at IS NULL) - OR edited_at = CONVERT(datetime2, @10, 127) - )`, + AND revision_token = CONVERT(uniqueidentifier, @10)`, [ data.description, data.acceptanceCriteria ?? null, @@ -847,15 +864,15 @@ export async function editRequirement( data.requiresTesting ? 1 : 0, verificationMethod, now, - current.id, - expectedEditedAt, + baseVersionId, + baseRevisionToken, ], )) as Array> if (!updateRows[0]) { throw staleRequirementEditError( requirementId, - data.expectedEditedAt ?? null, - current.editedAt, + baseVersionId, + current.id, ) } result = mapVersion(updateRows[0] ?? {}) @@ -955,7 +972,9 @@ export async function initiateArchiving( const now = new Date() await db.query( `UPDATE requirement_versions - SET requirement_status_id = ${STATUS_REVIEW}, archive_initiated_at = @0 + SET requirement_status_id = ${STATUS_REVIEW}, + archive_initiated_at = @0, + revision_token = NEWID() WHERE id = @1`, [now, publishedId], ) @@ -991,7 +1010,8 @@ export async function approveArchiving( `UPDATE requirement_versions SET requirement_status_id = ${STATUS_ARCHIVED}, archived_at = @0, - archive_initiated_at = NULL + archive_initiated_at = NULL, + revision_token = NEWID() WHERE id = @1`, [now, versionId], ) @@ -1018,7 +1038,9 @@ export async function cancelArchiving( } await db.query( `UPDATE requirement_versions - SET requirement_status_id = ${STATUS_PUBLISHED}, archive_initiated_at = NULL + SET requirement_status_id = ${STATUS_PUBLISHED}, + archive_initiated_at = NULL, + revision_token = NEWID() WHERE id = @0`, [Number(rows[0].id)], ) @@ -1147,7 +1169,9 @@ export async function transitionStatus( params.P_published = now await tx.query( `UPDATE requirement_versions - SET requirement_status_id = ${STATUS_ARCHIVED}, archived_at = @0 + SET requirement_status_id = ${STATUS_ARCHIVED}, + archived_at = @0, + revision_token = NEWID() WHERE requirement_id = @1 AND requirement_status_id = ${STATUS_PUBLISHED}`, [now, requirementId], ) @@ -1185,7 +1209,7 @@ export async function transitionStatus( const idParamIndex = positionalValues.length - 1 const updateRows = (await tx.query( - `UPDATE requirement_versions SET ${resolvedSets} ${VERSION_OUTPUT} WHERE id = @${idParamIndex}`, + `UPDATE requirement_versions SET ${resolvedSets}, revision_token = NEWID() ${VERSION_OUTPUT} WHERE id = @${idParamIndex}`, positionalValues, )) as Array> if (!updateRows[0]) { @@ -1311,6 +1335,7 @@ export async function getVersionHistory( const rows = (await db.query( `SELECT version.id AS id, + version.revision_token AS revisionToken, version.requirement_id AS requirementId, version.version_number AS versionNumber, version.description AS description, @@ -1387,6 +1412,7 @@ export async function getVersionHistory( const statusRowId = toNum(row.statusRowId) return { id: versionId, + revisionToken: String(row.revisionToken ?? '').toLowerCase(), requirementId: Number(row.requirementId), versionNumber: Number(row.versionNumber), description: String(row.description ?? ''), @@ -1478,6 +1504,7 @@ export async function getRequirementById(db: SqlServerDatabase, id: number) { const versionRows = (await db.query( `SELECT version.id AS id, + version.revision_token AS revisionToken, version.requirement_id AS requirementId, version.version_number AS versionNumber, version.description AS description, @@ -1609,6 +1636,7 @@ export async function getRequirementById(db: SqlServerDatabase, id: number) { const statusRowId = toNum(row.statusRowId) return { id: vId, + revisionToken: String(row.revisionToken ?? '').toLowerCase(), requirementId: Number(row.requirementId), versionNumber: Number(row.versionNumber), description: String(row.description ?? ''), diff --git a/lib/requirements/list-view.ts b/lib/requirements/list-view.ts index 07455a565..87aec9d43 100644 --- a/lib/requirements/list-view.ts +++ b/lib/requirements/list-view.ts @@ -93,6 +93,7 @@ export interface RequirementRow { categoryNameSv: string | null description: string | null requiresTesting: boolean + revisionToken?: string riskLevelId: number | null riskLevelNameEn: string | null riskLevelNameSv: string | null diff --git a/lib/requirements/service.ts b/lib/requirements/service.ts index 6f977290b..f7ce133f6 100644 --- a/lib/requirements/service.ts +++ b/lib/requirements/service.ts @@ -121,10 +121,11 @@ export type CatalogKind = export interface RequirementMutationInput { acceptanceCriteria?: string areaId?: number + baseRevisionToken?: string | null + baseVersionId?: number | null categoryId?: number createdBy?: string description?: string - expectedEditedAt?: string | null normReferenceIds?: number[] qualityCharacteristicId?: number requiresTesting?: boolean @@ -370,6 +371,7 @@ function formatRequirementListItem( categoryNameSv: item.categoryNameSv, description: item.description, id: item.versionId, + revisionToken: item.revisionToken, requiresTesting: item.requiresTesting, riskLevelId: item.riskLevelId, riskLevelNameEn: item.riskLevelNameEn, @@ -430,6 +432,7 @@ function formatRequirementDetail( ownerName: version.createdBy ?? null, publishedAt: version.publishedAt, requiresTesting: version.requiresTesting, + revisionToken: version.revisionToken, verificationMethod: version.verificationMethod, status: version.status, statusColor: version.statusColor, @@ -1318,9 +1321,12 @@ export function createRequirementsService( 'Edit operation requires requirement.description', ) } - if (payload.expectedEditedAt === undefined) { + if ( + payload.baseVersionId == null || + payload.baseRevisionToken == null + ) { throw validationError( - 'Edit operation requires requirement.expectedEditedAt', + 'Edit operation requires requirement.baseVersionId and requirement.baseRevisionToken', { reason: 'missing_edit_precondition' }, ) } @@ -1331,9 +1337,10 @@ export function createRequirementsService( try { version = await editRequirement(db, requirementId, { acceptanceCriteria: payload.acceptanceCriteria, + baseRevisionToken: payload.baseRevisionToken, + baseVersionId: payload.baseVersionId, createdBy: payload.createdBy ?? context.actor.id ?? undefined, description: payload.description, - expectedEditedAt: payload.expectedEditedAt, normReferenceIds: payload.normReferenceIds, requirementAreaId: payload.areaId, requirementCategoryId: payload.categoryId, diff --git a/lib/requirements/types.ts b/lib/requirements/types.ts index 5f53252b9..22bab40fa 100644 --- a/lib/requirements/types.ts +++ b/lib/requirements/types.ts @@ -51,6 +51,7 @@ export interface RequirementVersionDetail { publishedAt: string | null qualityCharacteristic: RequirementLocalizedEntity | null requiresTesting: boolean + revisionToken: string riskLevel: RiskLevelSummary | null status: number statusColor: string | null From 5d0afc3ed0f5c472b937743a8e7b799f3d5fb874 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:00:45 +0000 Subject: [PATCH 12/45] fix: update draft edit concurrency scenario to include baseVersionId and baseRevisionToken --- .github/skills/run-spec-audit/references/scrutiny-areas.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/skills/run-spec-audit/references/scrutiny-areas.md b/.github/skills/run-spec-audit/references/scrutiny-areas.md index f6d2a8ea1..37fee7949 100644 --- a/.github/skills/run-spec-audit/references/scrutiny-areas.md +++ b/.github/skills/run-spec-audit/references/scrutiny-areas.md @@ -60,7 +60,8 @@ maintenance rule. ## 5. Draft Edit Concurrency — Scenario 11 - **Code:** `lib/dal/requirements.ts` and - `lib/requirements/service.ts` — optimistic edit preconditions. + `lib/requirements/service.ts` — `baseVersionId`/`baseRevisionToken` + optimistic edit preconditions. - **Spec:** `docs/lifecycle-workflow.md`. - **Req tag:** `[Req: formal — docs/lifecycle-workflow.md "Draft"]` - **Question:** Are stale draft edits rejected before content or joins are From 26d8764d210527f02563f1e6f105e7fd171aa372 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:00:50 +0000 Subject: [PATCH 13/45] fix: replace expectedEditedAt with baseRevisionToken and baseVersionId in RequirementForm --- components/RequirementForm.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/components/RequirementForm.tsx b/components/RequirementForm.tsx index 799ffa053..57a469ad9 100644 --- a/components/RequirementForm.tsx +++ b/components/RequirementForm.tsx @@ -14,7 +14,8 @@ import { apiFetch } from '@/lib/http/api-fetch' import type { RequirementDetailResponse } from '@/lib/requirements/types' interface RequirementFormProps { - expectedEditedAt?: string | null + baseRevisionToken?: string | null + baseVersionId?: number | null initialData?: Partial< Omit > @@ -58,7 +59,8 @@ const EMPTY_FORM: RequirementFormFieldValues = { } export default function RequirementForm({ - expectedEditedAt, + baseRevisionToken, + baseVersionId, initialData, initialNormReferenceIds, initialScenarioIds, @@ -174,7 +176,8 @@ export default function RequirementForm({ : undefined, riskLevelId: form.riskLevelId ? Number(form.riskLevelId) : undefined, description: form.description || undefined, - expectedEditedAt: mode === 'edit' ? expectedEditedAt : undefined, + baseRevisionToken: mode === 'edit' ? baseRevisionToken : undefined, + baseVersionId: mode === 'edit' ? baseVersionId : undefined, acceptanceCriteria: form.acceptanceCriteria || undefined, requiresTesting: form.requiresTesting, verificationMethod: form.requiresTesting From 67c52ad1fd962e4c81dd43743012f3bd8ad3f3d1 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:00:58 +0000 Subject: [PATCH 14/45] fix: replace expectedEditedAt with baseRevisionToken and baseVersionId across requirement editing logic --- tests/quality/QUALITY.md | 17 ++- tests/quality/functional.test.ts | 31 ++-- tests/unit/edit-requirement-client.test.tsx | 20 ++- tests/unit/mcp-http.test.ts | 119 ++++++++++++++- tests/unit/requirement-detail-client.test.tsx | 1 + tests/unit/requirement-form.test.tsx | 16 +- tests/unit/requirements-client.test.tsx | 1 + tests/unit/requirements-dal.test.ts | 141 ++++++++++++++++-- tests/unit/requirements-id-route.test.ts | 11 +- tests/unit/requirements-service.test.ts | 16 +- tests/unit/version-detail-client.test.tsx | 1 + 11 files changed, 322 insertions(+), 52 deletions(-) diff --git a/tests/quality/QUALITY.md b/tests/quality/QUALITY.md index 097d89c6a..37c98308b 100644 --- a/tests/quality/QUALITY.md +++ b/tests/quality/QUALITY.md @@ -325,17 +325,18 @@ npm exec -- vitest run tests/quality/functional.test.ts -t "Scenario 10: MCP too **Requirement tag:** `[Req: formal — docs/lifecycle-workflow.md "Draft"]` **What happened:** Draft content is intentionally editable in place, but -`editRequirement()` now requires the caller's `expectedEditedAt` token to match -the latest draft row before it updates the row or rewrites its scenario and norm -reference joins at `lib/dal/requirements.ts:741-875`. The shared service -requires the token and adds the latest snapshot to stale conflict responses at -`lib/requirements/service.ts:1314-1357`. If those guards are removed, a second +`editRequirement()` now requires the caller's `baseVersionId` and +`baseRevisionToken` to match the latest draft row before it updates the row or +rewrites its scenario and norm reference joins at +`lib/dal/requirements.ts:798-895`. The shared service requires both base fields +and adds the latest snapshot to stale conflict responses at +`lib/requirements/service.ts:1317-1364`. If those guards are removed, a second editor can silently replace the first editor's saved content while still receiving a successful response. -**The requirement:** Editing a draft must be conditional on the `edited_at` -value captured when editing started. A stale edit must fail with a conflict and -must leave the latest saved version content unchanged. +**The requirement:** Editing a draft must be conditional on the version ID and +opaque revision token captured when editing started. A stale edit must fail with +a conflict and must leave the latest saved version content unchanged. **How to verify:** diff --git a/tests/quality/functional.test.ts b/tests/quality/functional.test.ts index dc5f015ae..3c841de00 100644 --- a/tests/quality/functional.test.ts +++ b/tests/quality/functional.test.ts @@ -290,8 +290,8 @@ async function createPublishedRequirement( areaId: number, description: string, ): Promise<{ - editedAt: string | null requirementId: number + revisionToken: string uniqueId: string publishedVersionId: number }> { @@ -306,8 +306,8 @@ async function createPublishedRequirement( STATUS_PUBLISHED, ) return { - editedAt: published.editedAt, requirementId: created.requirement.id, + revisionToken: published.revisionToken, uniqueId: created.requirement.uniqueId, publishedVersionId: published.id, } @@ -363,8 +363,9 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { ) await editRequirement(appDb(), published.requirementId, { + baseRevisionToken: published.revisionToken, + baseVersionId: published.publishedVersionId, description: 'Draft replacement that must stay private', - expectedEditedAt: published.editedAt, }) const service = createRequirementsService(appDb()) @@ -390,8 +391,9 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { ) await editRequirement(appDb(), published.requirementId, { + baseRevisionToken: published.revisionToken, + baseVersionId: published.publishedVersionId, description: 'Pending replacement', - expectedEditedAt: published.editedAt, }) await expect( @@ -412,8 +414,9 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { ) await editRequirement(appDb(), published.requirementId, { + baseRevisionToken: published.revisionToken, + baseVersionId: published.publishedVersionId, description: 'Version two draft', - expectedEditedAt: published.editedAt, }) await transitionStatus(appDb(), published.requirementId, STATUS_REVIEW) const republished = await transitionStatus( @@ -446,8 +449,9 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await expect( editRequirement(appDb(), created.requirement.id, { + baseRevisionToken: created.version.revisionToken, + baseVersionId: created.version.id, description: 'Illegal review edit', - expectedEditedAt: created.version.editedAt, }), ).rejects.toMatchObject({ code: 'conflict', @@ -462,8 +466,9 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { await expect( editRequirement(appDb(), created.requirement.id, { + baseRevisionToken: created.version.revisionToken, + baseVersionId: created.version.id, description: 'Illegal archived edit', - expectedEditedAt: created.version.editedAt, }), ).rejects.toMatchObject({ code: 'conflict', @@ -716,18 +721,24 @@ describeIfSqlServer('Fitness Scenarios (SQL Server)', () => { }) const firstSave = await editRequirement(appDb(), created.requirement.id, { + baseRevisionToken: created.version.revisionToken, + baseVersionId: created.version.id, description: 'First saved draft', - expectedEditedAt: created.version.editedAt, }) await expect( editRequirement(appDb(), created.requirement.id, { + baseRevisionToken: created.version.revisionToken, + baseVersionId: created.version.id, description: 'Stale overwrite attempt', - expectedEditedAt: created.version.editedAt, }), ).rejects.toMatchObject({ code: 'conflict', - details: { reason: 'stale_requirement_edit' }, + details: { + baseVersionId: created.version.id, + latestVersionId: created.version.id, + reason: 'stale_requirement_edit', + }, }) const history = await getVersionHistory(appDb(), created.requirement.id) diff --git a/tests/unit/edit-requirement-client.test.tsx b/tests/unit/edit-requirement-client.test.tsx index 1c7446fff..9d0adf90a 100644 --- a/tests/unit/edit-requirement-client.test.tsx +++ b/tests/unit/edit-requirement-client.test.tsx @@ -30,13 +30,15 @@ vi.mock('@/components/HelpPanel', () => ({ vi.mock('@/components/RequirementForm', () => ({ default: (props: { - expectedEditedAt?: string | null + baseRevisionToken?: string | null + baseVersionId?: number | null mode: string requirementId?: number | string initialData?: Record }) => (
{ okJson( makeRequirementDetailResponse( {}, - { editedAt: '2026-03-08T00:00:00.000Z' }, + { + id: 10, + revisionToken: '22222222-2222-4222-8222-222222222222', + }, ), ), ) @@ -143,8 +149,12 @@ describe('EditRequirementClient', () => { }) expect(screen.getByTestId('req-form')).toHaveAttribute('data-mode', 'edit') expect(screen.getByTestId('req-form')).toHaveAttribute( - 'data-expected-edited-at', - '2026-03-08T00:00:00.000Z', + 'data-base-version-id', + '10', + ) + expect(screen.getByTestId('req-form')).toHaveAttribute( + 'data-base-revision-token', + '22222222-2222-4222-8222-222222222222', ) expect(screen.getByText(/REQ-001/)).toBeInTheDocument() }) diff --git a/tests/unit/mcp-http.test.ts b/tests/unit/mcp-http.test.ts index 9c0369616..7c9edd0ba 100644 --- a/tests/unit/mcp-http.test.ts +++ b/tests/unit/mcp-http.test.ts @@ -54,6 +54,7 @@ function createFakeService( }, description: 'Support secure integration', id: 10, + revisionToken: '11111111-1111-4111-8111-111111111111', versionNormReferences: normReferences, requiresTesting, statusNameEn: 'Draft', @@ -85,6 +86,7 @@ function createFakeService( }, description: 'Support secure integration', id: 10, + revisionToken: '11111111-1111-4111-8111-111111111111', versionNormReferences: normReferences, requiresTesting, statusNameEn: 'Draft', @@ -106,7 +108,12 @@ function createFakeService( manageRequirement: vi.fn().mockResolvedValue({ detail: { uniqueId: 'INT0001', - versions: [{ versionNumber: 2 }], + versions: [ + { + revisionToken: '11111111-1111-4111-8111-111111111111', + versionNumber: 2, + }, + ], }, message: 'Requirement updated', operation: 'edit', @@ -122,6 +129,7 @@ function createFakeService( uniqueId: 'INT0001', version: { description: 'Support secure integration', + revisionToken: '11111111-1111-4111-8111-111111111111', versionNumber: 2, }, }, @@ -155,6 +163,7 @@ function createFakeService( }, message: 'Requirement transitioned', version: { + revisionToken: '11111111-1111-4111-8111-111111111111', versionNumber: 2, }, }), @@ -222,6 +231,76 @@ describe('handleRequirementsMcpRequest', () => { 'requirements_transition_requirement', ]), ) + const manageTool = tools.tools.find( + tool => tool.name === 'requirements_manage_requirement', + ) + const queryTool = tools.tools.find( + tool => tool.name === 'requirements_query_catalog', + ) + const getRequirementTool = tools.tools.find( + tool => tool.name === 'requirements_get_requirement', + ) + const transitionTool = tools.tools.find( + tool => tool.name === 'requirements_transition_requirement', + ) + const listSuggestionsTool = tools.tools.find( + tool => tool.name === 'requirements_list_improvement_suggestions', + ) + const manageSuggestionTool = tools.tools.find( + tool => tool.name === 'requirements_manage_improvement_suggestion', + ) + const generateTool = tools.tools.find( + tool => tool.name === 'requirements_generate_requirements', + ) + expect(queryTool?.description).toContain('risk_levels') + const queryInputSchemaText = JSON.stringify(queryTool?.inputSchema) + expect(queryInputSchemaText).toContain('risk_levels') + expect(queryInputSchemaText).toContain('normReferenceIds') + expect(queryInputSchemaText).toContain('usageScenarioIds') + expect(queryInputSchemaText).toContain('sortBy') + expect(JSON.stringify(queryTool?.outputSchema)).toContain('pagination') + expect(getRequirementTool?.description).toContain('view: "history"') + const getRequirementInputSchemaText = JSON.stringify( + getRequirementTool?.inputSchema, + ) + expect(getRequirementInputSchemaText).toContain('history') + expect(getRequirementInputSchemaText).toContain('before editing') + const getRequirementOutputSchemaText = JSON.stringify( + getRequirementTool?.outputSchema, + ) + expect(getRequirementOutputSchemaText).toContain('revisionToken') + expect(getRequirementOutputSchemaText).toContain('baseRevisionToken') + expect(manageTool?.description).toContain('requirement.baseVersionId') + expect(manageTool?.description).toContain('requirement.baseRevisionToken') + expect(manageTool?.description).toContain('operation "create"') + expect(manageTool?.description).toContain('requirement.areaId') + expect(manageTool?.description).toContain('requirement.description') + expect(manageTool?.description).toContain('view: "history"') + const manageInputSchemaText = JSON.stringify(manageTool?.inputSchema) + expect(manageInputSchemaText).toContain('For create') + expect(manageInputSchemaText).toContain('acceptanceCriteria') + expect(manageInputSchemaText).toContain('requirement.versions[0].id') + expect(manageInputSchemaText).toContain( + 'requirement.versions[0].revisionToken', + ) + expect(transitionTool?.description).toContain('rotates the version') + expect(JSON.stringify(transitionTool?.outputSchema)).toContain( + 'revisionToken', + ) + expect(JSON.stringify(listSuggestionsTool?.outputSchema)).toContain( + 'suggestions', + ) + const manageSuggestionInputSchemaText = JSON.stringify( + manageSuggestionTool?.inputSchema, + ) + expect(manageSuggestionInputSchemaText).toContain('resolutionMotivation') + expect(JSON.stringify(manageSuggestionTool?.outputSchema)).toContain( + 'result', + ) + const generateInputSchemaText = JSON.stringify(generateTool?.inputSchema) + expect(generateInputSchemaText).toContain('"maxLength":1000') + expect(generateTool?.description).toContain('using the generated fields') + expect(JSON.stringify(generateTool?.outputSchema)).toContain('stats') const resource = await client.readResource({ uri: 'requirements://requirement/INT0001?version=2', @@ -301,6 +380,38 @@ describe('handleRequirementsMcpRequest', () => { await transport.close() }) + it('passes requirement catalog filters and sorting through the MCP schema', async () => { + const { client, transport } = await createClient() + const fakeService = serviceState.getService.mock.results[0]?.value + + const result = await client.callTool({ + arguments: { + catalog: 'requirements', + normReferenceIds: [4], + riskLevelIds: [2], + sortBy: 'riskLevel', + sortDirection: 'desc', + usageScenarioIds: [3], + }, + name: 'requirements_query_catalog', + }) + + expect(result.isError).not.toBe(true) + expect(fakeService.queryCatalog).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + normReferenceIds: [4], + riskLevelIds: [2], + sortBy: 'riskLevel', + sortDirection: 'desc', + usageScenarioIds: [3], + }), + ) + + await client.close() + await transport.close() + }) + it('returns tool-level errors as isError results instead of protocol failures', async () => { const fakeService = createFakeService() fakeService.getRequirement.mockRejectedValueOnce(new Error('Boom')) @@ -534,8 +645,9 @@ describe('handleRequirementsMcpRequest', () => { operation: 'edit', uniqueId: 'INT0001', requirement: { + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, description: 'Updated description', - expectedEditedAt: '2026-03-08T00:00:00.000Z', normReferenceIds: [1, 2], }, }, @@ -547,7 +659,8 @@ describe('handleRequirementsMcpRequest', () => { expect.anything(), expect.objectContaining({ requirement: expect.objectContaining({ - expectedEditedAt: '2026-03-08T00:00:00.000Z', + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, normReferenceIds: [1, 2], }), }), diff --git a/tests/unit/requirement-detail-client.test.tsx b/tests/unit/requirement-detail-client.test.tsx index 7973ff1ea..37edf9de8 100644 --- a/tests/unit/requirement-detail-client.test.tsx +++ b/tests/unit/requirement-detail-client.test.tsx @@ -252,6 +252,7 @@ function makeVersion( ownerName: 'Owner', publishedAt: null, requiresTesting: false, + revisionToken: `11111111-1111-4111-8111-${String(versionNumber).padStart(12, '0')}`, qualityCharacteristic: toLocalizedEntity(qualityCharacteristic, 30), riskLevel: null, status: 1, diff --git a/tests/unit/requirement-form.test.tsx b/tests/unit/requirement-form.test.tsx index dfda82894..a262c0520 100644 --- a/tests/unit/requirement-form.test.tsx +++ b/tests/unit/requirement-form.test.tsx @@ -172,7 +172,8 @@ describe('RequirementForm', () => { const { container } = render( { (c[1] as RequestInit)?.method === 'PUT', ) const body = JSON.parse((putCall?.[1] as RequestInit).body as string) - expect(body.expectedEditedAt).toBe('2026-03-08T00:00:00.000Z') + expect(body.baseRevisionToken).toBe('11111111-1111-4111-8111-111111111111') + expect(body.baseVersionId).toBe(10) }) it('shows a stale edit conflict prompt without clearing form data', async () => { @@ -211,7 +213,12 @@ describe('RequirementForm', () => { details: { latest: { uniqueId: 'REQ-001', - versions: [{ versionNumber: 2 }], + versions: [ + { + revisionToken: '22222222-2222-4222-8222-222222222222', + versionNumber: 2, + }, + ], }, reason: 'stale_requirement_edit', }, @@ -240,7 +247,8 @@ describe('RequirementForm', () => { const { container } = render( Promise>() const getRepository = vi.fn() const transaction = vi.fn( - async (callback: (manager: { query: typeof query }) => Promise) => - callback({ query }), + async ( + isolationOrCallback: + | string + | ((manager: { query: typeof query }) => Promise), + maybeCallback?: (manager: { query: typeof query }) => Promise, + ) => { + const callback = + typeof isolationOrCallback === 'function' + ? isolationOrCallback + : maybeCallback + if (!callback) throw new Error('Missing transaction callback') + return callback({ query }) + }, ) const db = { getRepository, @@ -63,6 +74,7 @@ describe('requirements DAL (SQL Server path)', () => { .mockResolvedValueOnce([ { id: 21, + revisionToken: '11111111-1111-4111-8111-111111111111', requirementId: 7, versionNumber: 2, description: 'desc-v2', @@ -156,6 +168,7 @@ describe('requirements DAL (SQL Server path)', () => { expect(result?.versions).toHaveLength(1) const version = result?.versions[0] expect(version?.id).toBe(21) + expect(version?.revisionToken).toBe('11111111-1111-4111-8111-111111111111') expect(version?.versionNumber).toBe(2) expect(version?.requiresTesting).toBe(true) expect(version?.status).toBe(3) @@ -230,33 +243,77 @@ describe('requirements DAL (SQL Server path)', () => { expect(query).toHaveBeenCalledTimes(1) }) - it('rejects stale draft edits before rewriting joins', async () => { + it('rejects a stale base version id before updating a draft', async () => { const { db, query } = createSqlServerDb() - const editedAt = new Date('2026-04-20T08:30:00.000Z') - query - .mockResolvedValueOnce([ - { - id: 21, - statusId: 1, - editedAt, - }, - ]) - .mockResolvedValueOnce([]) + query.mockResolvedValueOnce([ + { + id: 22, + revisionToken: '11111111-1111-4111-8111-111111111111', + statusId: 1, + }, + ]) + + await expect( + editRequirement(db, 7, { + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 21, + description: 'Stale update', + normReferenceIds: [100], + scenarioIds: [200], + }), + ).rejects.toMatchObject({ + code: 'conflict', + details: { + baseVersionId: 21, + latestVersionId: 22, + reason: 'stale_requirement_edit', + }, + }) + + const sqlCalls = query.mock.calls.map(([sql]) => String(sql)) + expect(sqlCalls).toHaveLength(1) + expect(sqlCalls[0]).toContain('WITH (UPDLOCK, HOLDLOCK)') + expect( + sqlCalls.some(sql => + sql.includes('DELETE FROM requirement_version_usage_scenarios'), + ), + ).toBe(false) + expect( + sqlCalls.some(sql => + sql.includes('DELETE FROM requirement_version_norm_references'), + ), + ).toBe(false) + }) + + it('rejects a stale base revision token before rewriting joins', async () => { + const { db, query } = createSqlServerDb() + query.mockResolvedValueOnce([ + { + id: 21, + revisionToken: '22222222-2222-4222-8222-222222222222', + statusId: 1, + }, + ]) await expect( editRequirement(db, 7, { + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 21, description: 'Stale update', - expectedEditedAt: editedAt.toISOString(), normReferenceIds: [100], scenarioIds: [200], }), ).rejects.toMatchObject({ code: 'conflict', - details: { reason: 'stale_requirement_edit' }, + details: { + baseVersionId: 21, + latestVersionId: 21, + reason: 'stale_requirement_edit', + }, }) const sqlCalls = query.mock.calls.map(([sql]) => String(sql)) - expect(sqlCalls[1]).toContain('edited_at = CONVERT(datetime2, @10, 127)') + expect(sqlCalls).toHaveLength(1) expect( sqlCalls.some(sql => sql.includes('DELETE FROM requirement_version_usage_scenarios'), @@ -268,4 +325,56 @@ describe('requirements DAL (SQL Server path)', () => { ), ).toBe(false) }) + + it('rotates the revision token when updating a draft', async () => { + const { db, query } = createSqlServerDb() + query + .mockResolvedValueOnce([ + { + id: 21, + revisionToken: '11111111-1111-4111-8111-111111111111', + statusId: 1, + }, + ]) + .mockResolvedValueOnce([ + { + acceptanceCriteria: null, + archiveInitiatedAt: null, + archivedAt: null, + createdAt: new Date('2026-04-20T08:30:00.000Z'), + createdBy: 'anna', + description: 'Updated draft', + editedAt: new Date('2026-04-20T09:30:00.000Z'), + id: 21, + publishedAt: null, + qualityCharacteristicId: null, + requirementCategoryId: null, + requirementId: 7, + requirementTypeId: null, + requiresTesting: 0, + revisionToken: '22222222-2222-4222-8222-222222222222', + riskLevelId: null, + statusId: 1, + verificationMethod: null, + versionNumber: 2, + }, + ]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + + const result = await editRequirement(db, 7, { + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 21, + description: 'Updated draft', + normReferenceIds: [], + scenarioIds: [], + }) + + const sqlCalls = query.mock.calls.map(([sql]) => String(sql)) + expect(sqlCalls[1]).toContain('revision_token = NEWID()') + expect(sqlCalls[1]).toContain( + 'revision_token = CONVERT(uniqueidentifier, @10)', + ) + expect(result.revisionToken).toBe('22222222-2222-4222-8222-222222222222') + }) }) diff --git a/tests/unit/requirements-id-route.test.ts b/tests/unit/requirements-id-route.test.ts index e42b9c0d4..e6b2b224c 100644 --- a/tests/unit/requirements-id-route.test.ts +++ b/tests/unit/requirements-id-route.test.ts @@ -107,8 +107,9 @@ describe('requirements/[id] route', () => { const req = new NextRequest('http://localhost/api/requirements/1', { method: 'PUT', body: JSON.stringify({ + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, description: 'Updated', - expectedEditedAt: '2026-03-08T00:00:00.000Z', references: [{ name: 'Ref1', uri: 'http://example.com' }], scenarioIds: [1, 2], }), @@ -125,7 +126,8 @@ describe('requirements/[id] route', () => { id: 1, operation: 'edit', requirement: expect.objectContaining({ - expectedEditedAt: '2026-03-08T00:00:00.000Z', + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, }), }), ) @@ -136,7 +138,9 @@ describe('requirements/[id] route', () => { Object.assign(new Error('This requirement was updated'), { code: 'conflict', details: { + baseVersionId: 10, latest: { uniqueId: 'REQ-001' }, + latestVersionId: 10, reason: 'stale_requirement_edit', }, status: 409, @@ -146,8 +150,9 @@ describe('requirements/[id] route', () => { const req = new NextRequest('http://localhost/api/requirements/1', { method: 'PUT', body: JSON.stringify({ + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, description: 'Updated', - expectedEditedAt: '2026-03-08T00:00:00.000Z', }), headers: { 'Content-Type': 'application/json' }, }) diff --git a/tests/unit/requirements-service.test.ts b/tests/unit/requirements-service.test.ts index 38e6fee92..8ccd2ff5b 100644 --- a/tests/unit/requirements-service.test.ts +++ b/tests/unit/requirements-service.test.ts @@ -119,6 +119,7 @@ function makeRequirementRecord() { id: 10, publishedAt: null, requiresTesting: true, + revisionToken: '11111111-1111-4111-8111-111111111111', status: 1, statusColor: '#3b82f6', statusNameEn: 'Draft', @@ -260,6 +261,7 @@ describe('createRequirementsService', () => { qualityCharacteristicId: 9, requirementTypeId: 1, requiresTesting: true, + revisionToken: '11111111-1111-4111-8111-111111111111', status: 3, statusColor: '#22c55e', statusNameEn: 'Published', @@ -320,6 +322,7 @@ describe('createRequirementsService', () => { qualityCharacteristicId: null, requirementTypeId: null, requiresTesting: false, + revisionToken: '11111111-1111-4111-8111-111111111111', status: 4, statusColor: '#6b7280', statusNameEn: 'Archived', @@ -714,8 +717,9 @@ describe('createRequirementsService', () => { id: 1, operation: 'edit', requirement: { + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, description: 'Updated text', - expectedEditedAt: '2026-03-08T00:00:00.000Z', }, }) expect(result.operation).toBe('edit') @@ -723,7 +727,8 @@ describe('createRequirementsService', () => { expect.anything(), 1, expect.objectContaining({ - expectedEditedAt: '2026-03-08T00:00:00.000Z', + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, }), ) }) @@ -750,6 +755,8 @@ describe('createRequirementsService', () => { it('adds the latest requirement snapshot to stale edit conflicts', async () => { mocks.editRequirement.mockRejectedValue( conflictError('This requirement was updated after you started editing.', { + baseVersionId: 10, + latestVersionId: 10, reason: 'stale_requirement_edit', }), ) @@ -763,14 +770,17 @@ describe('createRequirementsService', () => { id: 1, operation: 'edit', requirement: { + baseRevisionToken: '11111111-1111-4111-8111-111111111111', + baseVersionId: 10, description: 'Updated text', - expectedEditedAt: '2026-03-08T00:00:00.000Z', }, }), ).rejects.toMatchObject({ code: 'conflict', details: { + baseVersionId: 10, latest: expect.objectContaining({ uniqueId: 'INT0001' }), + latestVersionId: 10, reason: 'stale_requirement_edit', }, }) diff --git a/tests/unit/version-detail-client.test.tsx b/tests/unit/version-detail-client.test.tsx index 522445a0f..c502869ad 100644 --- a/tests/unit/version-detail-client.test.tsx +++ b/tests/unit/version-detail-client.test.tsx @@ -37,6 +37,7 @@ function makeVersion( publishedAt: null, qualityCharacteristic: { id: 4, nameSv: 'TC', nameEn: 'TypeCat' }, requiresTesting: true, + revisionToken: '11111111-1111-4111-8111-111111111111', riskLevel: null, status: 2, statusColor: '#eab308', From 867448b3208377cbe007504de8fea3e92e4c7059 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:02:10 +0000 Subject: [PATCH 15/45] fix: update requirement editing logic to use baseVersionId and baseRevisionToken --- docs/lifecycle-workflow.md | 7 ++++--- docs/mcp-server-contributor-guide.md | 17 ++++++++++++----- docs/mcp-server-user-guide.md | 24 +++++++++++++++++------- docs/version-lifecycle-dates.md | 24 +++++++++++++----------- 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/docs/lifecycle-workflow.md b/docs/lifecycle-workflow.md index a6071c9da..d616a1132 100644 --- a/docs/lifecycle-workflow.md +++ b/docs/lifecycle-workflow.md @@ -20,9 +20,10 @@ stateDiagram-v2 ``` - **Draft:** Initial state. The requirement is being - authored or revised. Saving an edit requires the `edited_at` - value that was current when editing started; stale draft saves - are rejected instead of overwriting newer content. + authored or revised. Saving an edit requires the latest version's + `id` as `baseVersionId` and opaque `revisionToken` as + `baseRevisionToken`; stale draft saves are rejected instead of + overwriting newer content. - **Review:** The requirement is under review. This state is used both for publishing review (Draft → Review) and archiving review (Published → Review). The system diff --git a/docs/mcp-server-contributor-guide.md b/docs/mcp-server-contributor-guide.md index 91237da3f..f20391624 100644 --- a/docs/mcp-server-contributor-guide.md +++ b/docs/mcp-server-contributor-guide.md @@ -91,7 +91,12 @@ Combines: - requirement listing - free-text search - lookup tables for areas, categories, types, quality characteristics, - statuses, scenarios, and transitions + risk levels, statuses, scenarios, and transitions + +Requirement search supports pagination, sorting, archive inclusion, taxonomy +filters, status/testing filters, norm-reference filters, and usage-scenario +filters. Lookup catalogs ignore requirement-only filters except +`typeId`, which filters the `quality_characteristics` catalog. This avoids a larger set of narrowly scoped read tools. @@ -117,10 +122,12 @@ Supports: - `delete_draft` - `restore_version` -Edit calls must include `requirement.expectedEditedAt`, copied from the -latest version's `editedAt` value returned by `requirements_get_requirement`. -The shared service maps stale tokens to `409 Conflict` details with -`reason: "stale_requirement_edit"` and the latest requirement snapshot. +Edit calls must first fetch the requirement with `view: "history"` and copy +`requirement.versions[0].id` and `requirement.versions[0].revisionToken` into +`requirement.baseVersionId` and +`requirement.baseRevisionToken`. The shared service maps stale base-version +tokens to `409 Conflict` details with `reason: "stale_requirement_edit"` and +the latest requirement snapshot. The shared service also supports `reactivate` for REST parity, but that operation is intentionally not exposed as an MCP tool operation in v1. diff --git a/docs/mcp-server-user-guide.md b/docs/mcp-server-user-guide.md index 66be7f7e5..437c126d0 100644 --- a/docs/mcp-server-user-guide.md +++ b/docs/mcp-server-user-guide.md @@ -24,14 +24,17 @@ agents can use it reliably. - `requirements_query_catalog` List or search requirements and fetch lookup catalogs such as areas, - categories, types, type categories, statuses, scenarios, and transitions. + categories, types, quality characteristics, risk levels, statuses, + scenarios, and transitions. - `requirements_get_requirement` Fetch the current requirement detail, a specific version, or full version history. - `requirements_manage_requirement` Create, edit, archive, delete the latest draft, or restore a historical - version. For `operation: "edit"`, first fetch the requirement and pass the - version's current `editedAt` value back as `requirement.expectedEditedAt`. + version. For `operation: "edit"`, first fetch the requirement with + `view: "history"` and pass `requirement.versions[0].id` and + `requirement.versions[0].revisionToken` back as + `requirement.baseVersionId` and `requirement.baseRevisionToken`. - `requirements_transition_requirement` Move a requirement through the lifecycle using a target status ID. @@ -325,10 +328,12 @@ data first: This is especially useful because transitions use `toStatusId`, and creation or editing may require IDs for areas and classification fields. -For edits, also fetch the requirement immediately before preparing the edit. -Use the latest version's `editedAt` value as `requirement.expectedEditedAt`. -If the server returns `409 Conflict` with `reason: "stale_requirement_edit"`, -read the returned latest snapshot and compare before retrying. +For edits, also fetch the requirement immediately before preparing the edit +with `view: "history"`. Use `requirement.versions[0].id` as +`requirement.baseVersionId` and `requirement.versions[0].revisionToken` as +`requirement.baseRevisionToken`. If the server returns `409 Conflict` with +`reason: "stale_requirement_edit"`, read the returned latest snapshot and +compare before retrying. ### 2. Prefer `uniqueId` @@ -391,8 +396,13 @@ tool. For requirement lists, it supports: - `categoryIds` - `typeIds` - `qualityCharacteristicIds` +- `riskLevelIds` +- `normReferenceIds` +- `usageScenarioIds` - `statuses` - `requiresTesting` +- `sortBy` +- `sortDirection` ## Example Tasks diff --git a/docs/version-lifecycle-dates.md b/docs/version-lifecycle-dates.md index 06a6035d3..f651f4e3e 100644 --- a/docs/version-lifecycle-dates.md +++ b/docs/version-lifecycle-dates.md @@ -256,21 +256,23 @@ The version history pills show the relevant date per status: `published_at` and `archived_at` are `NULL`. - **Editing a requirement** (`editRequirement`): When the current version is Draft, updates the existing row in place with - `edited_at` set to the current time, but only when the caller's - `expectedEditedAt` value still matches the row's previous - `edited_at`. When the current version is Published, creates a new - Draft version with `edited_at` set to the current time after the - same precondition check. **Not allowed** when the current version is - in Review or Archived status. + `edited_at` set to the current time and rotates `revision_token`, + but only when the caller's `baseVersionId` and + `baseRevisionToken` still match the latest version row. When the + current version is Published, creates a new Draft version with + `edited_at` set to the current time after the same precondition + check. **Not allowed** when the current version is in Review or + Archived status. - **Transitioning status** (`transitionStatus`): In-place `UPDATE` on the existing version row. Sets `statusId` to the target status. Sets `published_at` or `archived_at` when transitioning to Published or Archived respectively. - **Never** touches `edited_at`. **Never** creates a new version - row. When publishing, auto-archives any previously published - version of the same requirement. For archived requirements - with a pending Draft or Review replacement, `is_archived` - stays `true` until that replacement version is published. + Rotates `revision_token` because the row changed, but **never** + touches `edited_at`. **Never** creates a new version row. When + publishing, auto-archives any previously published version of the + same requirement. For archived requirements with a pending Draft or + Review replacement, `is_archived` stays `true` until that + replacement version is published. - **Initiating archiving** (`initiateArchiving`): In-place `UPDATE` on the existing version row. Sets `statusId` to Review and `archive_initiated_at` to the current time. From c6efaf4dbe572ebf99a8adf10a22ed3335955a30 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 05:56:02 +0000 Subject: [PATCH 16/45] fix: refactor UP_STATEMENTS for clarity and structure in requirement_versions migration --- .../migrations/0002_requirement_version_revision_token.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/typeorm/migrations/0002_requirement_version_revision_token.mjs b/typeorm/migrations/0002_requirement_version_revision_token.mjs index 7f46fde43..738a9f710 100644 --- a/typeorm/migrations/0002_requirement_version_revision_token.mjs +++ b/typeorm/migrations/0002_requirement_version_revision_token.mjs @@ -1,5 +1,9 @@ const UP_STATEMENTS = [ - "IF COL_LENGTH(N'dbo.requirement_versions', N'revision_token') IS NULL\nBEGIN\n ALTER TABLE [requirement_versions] ADD [revision_token] uniqueidentifier NULL;\n UPDATE [requirement_versions] SET [revision_token] = NEWID() WHERE [revision_token] IS NULL;\n ALTER TABLE [requirement_versions] ALTER COLUMN [revision_token] uniqueidentifier NOT NULL;\n ALTER TABLE [requirement_versions] ADD CONSTRAINT [df_requirement_versions_revision_token] DEFAULT NEWID() FOR [revision_token];\n CREATE UNIQUE INDEX [uq_requirement_versions_revision_token] ON [requirement_versions] ([revision_token]);\nEND", + "IF COL_LENGTH(N'dbo.requirement_versions', N'revision_token') IS NULL ALTER TABLE [requirement_versions] ADD [revision_token] uniqueidentifier NULL;", + 'UPDATE [requirement_versions] SET [revision_token] = NEWID() WHERE [revision_token] IS NULL;', + "IF EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'dbo.requirement_versions') AND name = N'revision_token' AND is_nullable = 1) ALTER TABLE [requirement_versions] ALTER COLUMN [revision_token] uniqueidentifier NOT NULL;", + "IF NOT EXISTS (SELECT 1 FROM sys.default_constraints WHERE name = N'df_requirement_versions_revision_token' AND parent_object_id = OBJECT_ID(N'dbo.requirement_versions')) ALTER TABLE [requirement_versions] ADD CONSTRAINT [df_requirement_versions_revision_token] DEFAULT NEWID() FOR [revision_token];", + "IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'uq_requirement_versions_revision_token' AND object_id = OBJECT_ID(N'dbo.requirement_versions')) CREATE UNIQUE INDEX [uq_requirement_versions_revision_token] ON [requirement_versions] ([revision_token]);", ] const DOWN_STATEMENTS = [ From aa73057755fc32a49dab78711a161e137a16ba7c Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 06:17:57 +0000 Subject: [PATCH 17/45] fix: update documentation to reflect changes in concurrency handling with baseVersionId and baseRevisionToken --- docs/database-schema.md | 1 + docs/version-lifecycle-dates.md | 9 +++++---- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/database-schema.md b/docs/database-schema.md index 7a408049c..ebe61a05c 100644 --- a/docs/database-schema.md +++ b/docs/database-schema.md @@ -1422,6 +1422,7 @@ graph LR R -- "idx_requirements_is_archived\n(is_archived)" --> R RV -- "uq_..._requirement_id_version_number\n(requirement_id, version_number)" --> R + RV -- "uq_requirement_versions_revision_token\n(revision_token)" --> RV RV -- "idx_..._requirement_id\n(requirement_id)" --> R RVS -- "idx_..._usage_scenario_id\n(usage_scenario_id)" --> RSC diff --git a/docs/version-lifecycle-dates.md b/docs/version-lifecycle-dates.md index f651f4e3e..42f83cee4 100644 --- a/docs/version-lifecycle-dates.md +++ b/docs/version-lifecycle-dates.md @@ -48,10 +48,11 @@ Editing is **not allowed** when the current version is in Review or Archived status. Review must first be moved back to Draft; Archived must be restored (which creates a new Draft version). -Edit requests must include the `edited_at` value that was current -when editing started. The server treats that value as an optimistic -concurrency token and rejects the save with `409 Conflict` if another -save has changed `edited_at` before the request arrives. +Edit requests must include the `baseVersionId` and +`baseRevisionToken` values that were current when editing started. +The server treats those normalized fields as optimistic concurrency +preconditions and rejects the save with `409 Conflict` if another save +has changed the latest version row before the request arrives. ## When `edited_at` Is Updated From 9c837a27161e8d26f0e23a45b0bbb181474f2910 Mon Sep 17 00:00:00 2001 From: Johan Ljunggren Date: Sat, 25 Apr 2026 06:18:06 +0000 Subject: [PATCH 18/45] fix: enhance requirement form with refresh handling and loading state --- components/RequirementForm.tsx | 67 +++++++++++++++++++++++----------- lib/dal/requirements.ts | 42 +++++++++------------ 2 files changed, 63 insertions(+), 46 deletions(-) diff --git a/components/RequirementForm.tsx b/components/RequirementForm.tsx index 57a469ad9..7fb6edd6f 100644 --- a/components/RequirementForm.tsx +++ b/components/RequirementForm.tsx @@ -89,6 +89,7 @@ export default function RequirementForm({ >([]) const [submitting, setSubmitting] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) const [error, setError] = useState(null) const [staleConflict, setStaleConflict] = useState<{ latest: RequirementDetailResponse | null @@ -242,15 +243,31 @@ export default function RequirementForm({ } const latestConflictVersion = staleConflict?.latest?.versions[0] - const latestConflictTarget = - staleConflict?.latest?.uniqueId ?? String(requirementId ?? '') - const latestConflictHref = latestConflictVersion?.versionNumber - ? `/requirements/${latestConflictTarget}/${latestConflictVersion.versionNumber}` - : `/requirements/${latestConflictTarget}` + const latestConflictTarget = staleConflict?.latest?.uniqueId + const latestConflictHref = staleConflict?.latest + ? latestConflictVersion?.versionNumber + ? `/requirements/${latestConflictTarget}/${latestConflictVersion.versionNumber}` + : `/requirements/${latestConflictTarget}` + : null + + const handleRefreshLatest = async () => { + if (!onRefreshLatest || isRefreshing) return + setIsRefreshing(true) + setError(null) + try { + await onRefreshLatest() + setStaleConflict(null) + } catch (e) { + setError(e instanceof Error ? e.message : tc('error')) + } finally { + setIsRefreshing(false) + } + } const normReferenceCreateButton = ( + {latestConflictHref && ( + + )} {onRefreshLatest && ( )}
@@ -392,12 +411,16 @@ export default function RequirementForm({
-