Skip to content

Commit 17543ba

Browse files
authored
feat(mvrs): add MVR supersession, latest dated MVR per SVC determines verdict (#399)
* feat(mvrs): supersession — latest MVR per SVC wins (#72) When an SVC is manually verified more than once (e.g. a first MVR fails, a fix is applied, and a second MVR passes), the requirement was incorrectly reported as not met because all MVRs were counted. Supersession rule: the MVR with the latest date is the verdict for that SVC; older ones are retained as audit history but excluded from the pass/fail computation. Changes: - Add optional `date` field (RFC 3339 date-time with timezone) to mvrs.yml schema; `date` is required when >1 MVR references the same SVC. - `MVRData.date: Optional[datetime]` — timezone-aware; stored as original ISO string in SQLite, sorted via SQLite's `datetime()` function so mixed offsets compare correctly by UTC moment. - `RequirementsRepository.get_effective_mvr_for_svc()` — new SQL query using `ORDER BY datetime(date) DESC NULLS LAST LIMIT 1`. - `RequirementsRepository.get_superseded_mvrs_for_svc()` — all non-effective MVRs for a SVC (audit history). - `StatisticsService` — verdict computed from effective MVR per SVC; removes the now-redundant `_get_mvr_stats` helper. - `details.py` — `_compute_meets` uses effective MVR; `get_svc_details` adds `date` and `superseded` fields to each MVR. - `SemanticValidator._validate_mvr_supersession` — errors on missing `date` when multiple MVRs reference the same SVC, and on exact UTC timestamp ties. - Report templates (AsciiDoc + Markdown) — show `date` and mark superseded MVRs as history. - Export service + schema — include `date` field. Closes #72 Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> * fix(mvrs): address PR review findings - export_service: serialize MVRData.date as ISO string (.isoformat()) to prevent TypeError on JSON export - details.py: serialize date in get_svc_details and get_mvr_details the same way; both previously passed raw datetime objects to callers - report.py: same fix for the Jinja2 template dict (renders T not space) - schema.py: add idx_mvrs_date index to support ORDER BY datetime(date) - requirements_repository: replace cascading-N+1 in get_superseded_mvrs_for_svc with a single ROW_NUMBER window query; add get_effective_mvr_verdict_counts() batch aggregation method; document the datetime() TZ-normalization in get_effective_mvr_for_svc - statistics_service: replace per-SVC loop in _calculate_global_totals with single get_effective_mvr_verdict_counts() call (eliminates N+1) - tests: add _validate_mvr_supersession unit tests (missing date, tie-break, valid pair, single undated); add supersession scenarios to test_details (compute_meets, get_svc_details superseded flag, get_mvr_details date field); add 3-MVR and global-totals tests to test_statistics_service; add date serialization round-trip tests to test_export_service Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> * docs: document MVR supersession and date field - usage.adoc: update Manual Tests column description to mention supersession; add new [[mvr-supersession]] section with YAML example, date field rules (required when >1 MVR per SVC, RFC 3339 format, tie-break is a validation error, quotes required in YAML) - how_it_works.adoc: add MVR verdict and supersession section explaining the single-query effective-MVR selection and global totals semantics Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> * docs: use RFC 3339 instead of ISO 8601 for MVR date field Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> * docs: update mcp.adoc and lsp.adoc for MVR date and superseded fields Signed-off-by: Jimisola Laursen <jimisola@jimisola.com> --------- Signed-off-by: Jimisola Laursen <jimisola@jimisola.com>
1 parent f8a7017 commit 17543ba

24 files changed

Lines changed: 784 additions & 50 deletions

File tree

docs/modules/ROOT/pages/how_it_works.adoc

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,16 @@ For each implementation node, `requirements.yml` is parsed (validation runs) but
3636

3737
NOTE: `imports:` sections of implementation nodes are not followed — an implementation's own imports belong to a different scope.
3838

39+
== MVR verdict and supersession
40+
41+
`StatisticsService` computes the manual-test verdict for each SVC by calling `RequirementsRepository.get_effective_mvr_for_svc()`.
42+
When a SVC has multiple MVRs, the one with the *latest `date`* (UTC-normalised via SQLite's `datetime()` function so mixed timezone offsets compare correctly) is the verdict; all others are superseded.
43+
When a SVC has only one MVR — dated or undated — that entry is always the verdict.
44+
45+
The global totals (`total_manual_tests`, `passed_manual_tests`, `failed_manual_tests`) are computed in a single SQL aggregation query and count one effective verdict per SVC, never superseded entries.
46+
47+
See xref:usage.adoc#mvr-supersession[MVR supersession] for the YAML format rules and examples.
48+
3949
== The `variant` field
4050

4151
`variant: system/microservice/external` in `requirements.yml` is optional advisory metadata. It is not a behavioral gate — parsing is entirely presence-based. If a file exists, it is read. If a section exists in YAML, it is parsed.

docs/modules/ROOT/pages/lsp.adoc

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -246,11 +246,11 @@ The full protocol spec (OpenRPC) is at `docs/modules/ROOT/lsp/reqstool-lsp.openr
246246

247247
| `svc`
248248
| `SVC_001`
249-
| Full SVC with linked requirements, test annotations, test results, and MVRs
249+
| Full SVC with linked requirements, test annotations, test results, and MVRs (each with `date` and `superseded` flag)
250250

251251
| `mvr`
252252
| `MVR_001`
253-
| Manual verification result with linked SVC IDs
253+
| Manual verification result with linked SVC IDs, RFC 3339 date, and `superseded` flag
254254

255255
| `urn`
256256
| `my-service`

docs/modules/ROOT/pages/mcp.adoc

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ Full details for a single SVC.
131131

132132
*Returns:* `{ type, id, urn, title, description, verification, instructions, revision, lifecycle, requirement_ids, test_annotations, test_results, test_summary, mvrs, location, source_paths }`
133133

134+
Each entry in `mvrs` is `{ id, urn, passed, date, comment, superseded }`.
135+
`date` is an RFC 3339 date-time string or empty string; `superseded: true` means an older entry that was superseded by a later one.
136+
134137
==== `get_mvr`
135138

136139
Full details for a single MVR.
@@ -139,7 +142,9 @@ Full details for a single MVR.
139142

140143
* `id` _(string, required)_
141144

142-
*Returns:* `{ type, id, urn, passed, comment, svc_ids, location, source_paths }`
145+
*Returns:* `{ type, id, urn, passed, date, comment, svc_ids, location, source_paths }`
146+
147+
`date` is an RFC 3339 date-time string, or an empty string if the MVR has no date.
143148

144149
==== `get_urn_details`
145150

docs/modules/ROOT/pages/usage.adoc

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,8 @@ The requirements table shows for each requirement:
108108
** `framework` -- `framework`: fulfilled by framework auto-configuration (Spring Boot starters, OTel agents); no annotation required
109109
** `N/A` -- not applicable, intentionally deferred, or out of scope; no annotation required
110110
* *Automated Tests* -- test results from JUnit XML: T=total SVCs, P=passed, F=failed, S=skipped, M=missing (no test found for SVC)
111-
* *Manual Tests* -- manual verification results: T=total, P=passed, F=failed, M=missing
111+
* *Manual Tests* -- manual verification results: T=total, P=passed, F=failed, M=missing.
112+
When multiple MVRs exist for the same SVC, only the one with the latest `date` counts (supersession); older ones are retained as audit history in the report but excluded from the verdict.
112113

113114
The summary panels below the table break down requirements by implementation type.
114115
The `In Code` panel shows total, verified, annotated-not-verified, and missing-annotation counts (all percentages relative to in-code requirements).
@@ -120,6 +121,38 @@ The `N/A`, `Configuration`, `Platform`, and `Framework` panels each show three c
120121

121122
The exit code equals the number of unmet requirements, making it suitable for CI/CD gates (`exit 0` = all requirements met).
122123

124+
[[mvr-supersession]]
125+
=== MVR supersession
126+
127+
When a manual verification is repeated (for example, a first attempt fails and a corrected one passes), add both results as separate MVR entries and set a `date` on each.
128+
Reqstool selects the entry with the *latest date* as the effective verdict for that SVC.
129+
Older entries are retained as audit history and shown with a `(superseded)` marker in the report, but are excluded from the pass/fail computation.
130+
131+
[source,yaml]
132+
----
133+
results:
134+
- id: MVR_001
135+
svc_ids: ["SVC_021"]
136+
date: "2026-01-10T09:00:00Z" # <1>
137+
pass: false
138+
comment: "XSD validation failed."
139+
140+
- id: MVR_002
141+
svc_ids: ["SVC_021"]
142+
date: "2026-01-15T14:30:00Z" # <2>
143+
pass: true
144+
comment: "All fields present."
145+
----
146+
<1> Older entry — superseded, shown as history in the report.
147+
<2> Latest entry — effective verdict; this determines pass/fail.
148+
149+
*`date` field rules:*
150+
151+
* The `date` value must be an *RFC 3339 date-time*, for example `"2026-01-15T14:30:00Z"` or `"2026-01-15T14:30:00+01:00"`. A timezone offset is mandatory — bare local datetimes without a timezone are rejected. Note the required quotes — unquoted values are parsed by YAML as `date` objects, not strings.
152+
* `date` is *optional* when only one MVR references a given SVC; the single entry is always the verdict regardless.
153+
* `date` is *required* on every MVR that references a given SVC as soon as more than one MVR does. A missing `date` in that situation is a semantic validation error.
154+
* Two MVRs for the same SVC with the *exact same UTC moment* are a validation error — add a time component to disambiguate (e.g. `T09:00:00Z` vs `T14:30:00Z`).
155+
123156
[[report]]
124157
== Command: report
125158

src/reqstool/commands/report/report.py

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
from reqstool.common.validators.semantic_validator import SemanticValidator
1515
from reqstool.locations.location import LocationInterface
1616
from reqstool.models.annotations import AnnotationData
17-
from reqstool.models.mvrs import MVRData
1817
from reqstool.models.svcs import SVCData
1918
from reqstool.models.test_data import TEST_RUN_STATUS
2019
from reqstool.services.statistics_service import StatisticsService
@@ -161,11 +160,21 @@ def __aggregated_requirements_data(
161160
# get all implementations for current requirement
162161
impls: list = self._get_annotation_impls(repo=repo, urn_id=urn_id)
163162

164-
# Get MVR IDs via SVCs
163+
# Get MVR IDs via SVCs, annotated with supersession state
165164
mvr_ids: list[UrnId] = [mid for svc_uid in svcs_urn_ids for mid in repo.get_mvrs_for_svc(svc_uid)]
166-
167-
# Get mvrs for current requirement if there are any (else [])
168-
mvrs: list[MVRData] = [all_mvrs[mvr_id] for mvr_id in mvr_ids if mvr_id in all_mvrs] if mvr_ids else []
165+
superseded_ids = {m.id for svc_uid in svcs_urn_ids for m in repo.get_superseded_mvrs_for_svc(svc_uid)}
166+
mvrs = [
167+
{
168+
"id": all_mvrs[mid].id,
169+
"passed": all_mvrs[mid].passed,
170+
"date": all_mvrs[mid].date.isoformat() if all_mvrs[mid].date is not None else "",
171+
"comment": all_mvrs[mid].comment,
172+
"svc_ids": all_mvrs[mid].svc_ids,
173+
"superseded": mid in superseded_ids,
174+
}
175+
for mid in mvr_ids
176+
if mid in all_mvrs
177+
]
169178

170179
# generate templates for tests related to current requirement
171180
automated_test_results_for_req: list = self._get_annotated_automated_test_results_for_req(

src/reqstool/commands/report/templates/asciidoc/mvrs.j2

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
[cols="1a,1"]
66
|===
77
|*MVR ID*|*Result*
8-
|{{mvr_info.id.urn}}:{{mvr_info.id.id}}| {%if mvr_info.passed%} passed {% else %} failed {%endif%}
8+
|{{mvr_info.id.urn}}:{{mvr_info.id.id}}{% if mvr_info.superseded %} _(superseded)_{%endif%}| {%if mvr_info.passed%} passed {% else %} failed {%endif%}
9+
{%if mvr_info.date%}
10+
2+|*Date*
11+
2+|{{mvr_info.date}}
12+
{%endif%}
913
2+|*Comment*
1014
2+|{{mvr_info.comment}}
1115
2+|*Referenced SVCs*

src/reqstool/commands/report/templates/markdown/mvrs.j2

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
| | |
66
|---|---|
77
| **MVR ID** | **Result** |
8-
| {{mvr_info.id.urn}}:{{mvr_info.id.id}} | {%if mvr_info.passed%}passed{% else %}failed{%endif%} |
8+
| {{mvr_info.id.urn}}:{{mvr_info.id.id}}{% if mvr_info.superseded %} *(superseded)*{%endif%} | {%if mvr_info.passed%}passed{% else %}failed{%endif%} |
9+
{%if mvr_info.date%}
10+
| **Date** | |
11+
| {{mvr_info.date}} | |
12+
{%endif%}
913
| **Comment** | |
1014
| {{mvr_info.comment}} | |
1115
| **Referenced SVCs** | |

src/reqstool/common/queries/details.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,11 @@ def _compute_meets(req, repo: RequirementsRepository, svc_urn_ids: list, all_pas
2424
return False
2525
else:
2626
raise ValueError(f"Unhandled IMPLEMENTATION value: {req.implementation}")
27-
# Check MVR results — automated test_summary misses manual verification outcomes
28-
all_mvrs = repo.get_all_mvrs()
27+
# Check MVR results using only the effective (latest) verdict per SVC
2928
for svc_uid in svc_urn_ids:
30-
for mvr_id in repo.get_mvrs_for_svc(svc_uid):
31-
mvr = all_mvrs.get(mvr_id)
32-
if mvr is not None and not mvr.passed:
33-
return False
29+
effective = repo.get_effective_mvr_for_svc(svc_uid)
30+
if effective is not None and not effective.passed:
31+
return False
3432
return True
3533

3634

@@ -109,9 +107,10 @@ def get_svc_details(
109107
if svc is None:
110108
return None
111109

112-
mvr_urn_ids = repo.get_mvrs_for_svc(svc.id)
113110
all_mvrs = repo.get_all_mvrs()
111+
mvr_urn_ids = repo.get_mvrs_for_svc(svc.id)
114112
mvrs = [all_mvrs[uid] for uid in mvr_urn_ids if uid in all_mvrs]
113+
superseded_ids = {m.id for m in repo.get_superseded_mvrs_for_svc(svc.id)}
115114

116115
test_annotations = repo.get_annotations_tests_for_svc(svc.id)
117116
test_results = repo.get_test_results_for_svc(svc.id)
@@ -154,7 +153,9 @@ def get_svc_details(
154153
"id": m.id.id,
155154
"urn": m.id.urn,
156155
"passed": m.passed,
156+
"date": m.date.isoformat() if m.date is not None else "",
157157
"comment": m.comment or "",
158+
"superseded": m.id in superseded_ids,
158159
}
159160
for m in mvrs
160161
],
@@ -181,6 +182,7 @@ def get_mvr_details(
181182
"id": mvr.id.id,
182183
"urn": mvr.id.urn,
183184
"passed": mvr.passed,
185+
"date": mvr.date.isoformat() if mvr.date is not None else "",
184186
"comment": mvr.comment or "",
185187
"svc_ids": [{"id": s.id, "urn": s.urn} for s in mvr.svc_ids],
186188
"location": repo.get_urn_location(mvr.id.urn),

src/reqstool/common/validators/semantic_validator.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from reqstool.common.utils import Utils
1414
from reqstool.common.validator_error_holder import ValidationError, ValidationErrorHolder
1515
from reqstool.models.imports import ImportDataInterface
16+
from reqstool.models.mvrs import MVRData
1617
from reqstool.models.raw_datasets import CombinedRawDataset
1718
from reqstool.models.requirements import RequirementData
1819
from reqstool.models.svcs import SVCData, SVCsData
@@ -47,6 +48,9 @@ def validate_post_parsing(self, combined_raw_dataset: CombinedRawDataset) -> Lis
4748
self._validation_error_holder.add_errors(
4849
self._validate_mvr_refers_to_existing_svc_ids(combined_raw_dataset=combined_raw_dataset)
4950
)
51+
self._validation_error_holder.add_errors(
52+
self._validate_mvr_supersession(combined_raw_dataset=combined_raw_dataset)
53+
)
5054

5155
errors = self._validation_error_holder.get_errors()
5256

@@ -215,6 +219,60 @@ def _validate_mvr_refers_to_existing_svc_ids(
215219

216220
return errors
217221

222+
def _validate_mvr_supersession(self, combined_raw_dataset: CombinedRawDataset) -> List[ValidationError]:
223+
"""Validate supersession rules when multiple MVRs reference the same SVC.
224+
225+
Rules:
226+
- If >1 MVR references the same SVC, every such MVR must have a ``date`` field.
227+
- No two MVRs for the same SVC may represent the exact same UTC moment (tie-break
228+
is an error — use a different timestamp to disambiguate).
229+
"""
230+
errors: List[ValidationError] = []
231+
for model, model_data in combined_raw_dataset.raw_datasets.items():
232+
if model != combined_raw_dataset.initial_model_urn or not model_data.mvrs_data:
233+
continue
234+
235+
svc_to_mvrs: dict[UrnId, List[MVRData]] = {}
236+
for mvr_data in model_data.mvrs_data.results.values():
237+
for svc_id in mvr_data.svc_ids:
238+
svc_to_mvrs.setdefault(svc_id, []).append(mvr_data)
239+
240+
for svc_id, mvrs in svc_to_mvrs.items():
241+
if len(mvrs) > 1:
242+
errors.extend(self._check_mvr_dates_for_svc(svc_id, mvrs))
243+
244+
return errors
245+
246+
def _check_mvr_dates_for_svc(self, svc_id: UrnId, mvrs: List[MVRData]) -> List[ValidationError]:
247+
errors: List[ValidationError] = []
248+
dated: list[tuple] = []
249+
for mvr in mvrs:
250+
if mvr.date is None:
251+
errors.append(
252+
ValidationError(
253+
msg=f"MVR '{self.prettify_urn_id(mvr.id)}' references SVC "
254+
f"'{self.prettify_urn_id(svc_id)}' but has no 'date' field. "
255+
f"A 'date' is required when multiple MVRs reference the same SVC."
256+
)
257+
)
258+
else:
259+
dated.append((mvr.date, mvr))
260+
261+
seen: dict = {}
262+
for ts, mvr in dated:
263+
if ts in seen:
264+
errors.append(
265+
ValidationError(
266+
msg=f"MVRs '{self.prettify_urn_id(seen[ts].id)}' and "
267+
f"'{self.prettify_urn_id(mvr.id)}' both reference SVC "
268+
f"'{self.prettify_urn_id(svc_id)}' at the same UTC moment. "
269+
f"Use a different timestamp to disambiguate."
270+
)
271+
)
272+
else:
273+
seen[ts] = mvr
274+
return errors
275+
218276
def _validate_svc_imports_filter_has_excludes_xor_includes(self, svc_data: SVCsData) -> List[ValidationError]:
219277
if "filters" in svc_data:
220278
for urn in svc_data["filters"].keys():

src/reqstool/model_generators/mvrs_model_generator.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ def __parse_mvrs(
7575
svc_ids=Utils.convert_ids_to_urn_id(ids=result.svc_ids, urn=self.urn),
7676
comment=result.comment,
7777
passed=result.pass_,
78+
date=result.date,
7879
source_line=source_lines[result.id][0] if result.id in source_lines else None,
7980
source_col_start=source_lines[result.id][1] if result.id in source_lines else None,
8081
source_col_end=source_lines[result.id][2] if result.id in source_lines else None,

0 commit comments

Comments
 (0)