[DT-3863] Reconcile legacy Data Use and recompute affected matches - #3029
[DT-3863] Reconcile legacy Data Use and recompute affected matches#3029kevinmarete wants to merge 16 commits into
Conversation
The row-mapping parser collapses a database null, an empty string, and malformed JSON to a null DataUse, so reconciliation cannot count them separately. Classify the raw value instead, reusing the canonical classifier once the value parses. The label is the only representation safe to report or log: it names categories and never the Other free text or the JSON that produced it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tions Reporting reads the raw data_use per dataset with the access management that decides its canonical shape, and counts classifications by label and by access management. Counts only, so a report can be attached to a ticket without exposing Other free text. The driver applies a supplied disposition per record: normalize through DatasetService.updateDatasetDataUse, which already validates, translates, audits, and syncs the index; recompute matches only; or defer. Dispositions are supplied rather than inferred, since choosing a primary category for an Other text is a domain judgement. Restartable by construction: a record already holding its approved value is skipped, compared by value rather than by classification so two disease lists sharing a shape are not conflated. Validation and not-found failures are permanent; anything else is retried once. The run report carries processed, skipped, failed and retried totals plus the failed dataset ids, so a rerun can be scoped to them. Does not join match_entity: it still keys datasets by the DUOS-###### alias, which the scoping audit recorded must not reach application code. Matches are found per DAR by reference_id instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Wires the report behind GET /api/datause/legacy/report, admin only, so the legacy population can be reconciled before and after normalization. The response carries classification counts only. The service now takes Jdbi and builds its own DAO, matching MatchService, with a package-private constructor for unit tests. DAO tests run against the real schema and cover what unit tests could not: the access-management COALESCE precedence, its casing and whitespace normalization, and that raw values keep null, empty, and malformed distinguishable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Selects datasets whose stored matches predate the abstention policy - no primary, Other-only, or more than one primary - and recomputes them per DAR. Changes no stored Data Use, so this half needs no approved disposition and can run ahead of the one record awaiting one. Recompute works per DAR, so a dataset with no dar_dataset relation is unreachable. The run logs how many rather than folding them into skipped, which would read as done. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Classification parses the Data Use JSON in Java, which SQL cannot do, so the read cannot be aggregated or narrowed further. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the two-continue loop with a single skip predicate, and drops the toMap merge function that groupingBy makes unreachable - forEachOrdered into a LinkedHashMap keeps the ordering without the dead branch. Adds direct tests for parse and for the report and row edge cases: empty population, absent classification, tie-broken ordering, null DAR counts, reconciliation, and missing access management. New classes are at 100% instruction and branch coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds GET /api/datause/legacy/noncanonical so correcting a record does not need a hand-written production query. Returns the dataset id with its classification label, access management, DAR count, and whether the recompute reaches it - never the stored Data Use or its Other text. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drops the separate report endpoint. The recompute now returns the classification counts either side of the run, so a recompute-only run can be seen to have left them unchanged without the operator bookending it with two manual calls. Leaves two endpoints and four operational steps. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
otchet-broad
left a comment
There was a problem hiding this comment.
Has this been tested against the production database? It would be good to understand the number of changes that are going to happen in that environment. I know there's a dry run feature, but if we've got branches in code for dev-only cases, it would be good to remove them and minimize what we need to do here.
Nothing constructs Normalize or Defer: the only endpoint passes
RecomputeMatchesOnly, and the one dataset needing correction goes through
PUT /api/dataset/{id}/datause. Removing them takes with it the skipped
counter, which was structurally always zero, the failuresByReason map,
whose only remaining key was "unexpected", the validation and not-found
branches, which could only be raised by the write, and the DatasetService
dependency.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Yes — DT-3861 ran read-only against the production database (all 5,811 datasets, no active/inactive filter), and those are the numbers in the description. The production change volume is bounded and small:
On dev-only branches: there are none — no dry-run flag and no environment conditional anywhere in the diff. The read-only There was a path we will never exercise, though, and cda4b1a removes it (−299 lines). |
fboulnois
left a comment
There was a problem hiding this comment.
Findings:
Correctness
1. findDarReferenceIdsByDatasetId is unfiltered by DAR state in PersistedDataUseDAO.java:59
The recompute creates match rows for unsubmitted draft DARs, and wipes historical V1/V4 match rows on archived DARs with closed elections. The dar_usage CTE (PersistedDataUseDAO.java:42) counts drafts too, which inflates darCount and needsMatchRecompute.
2. apply() commits the Data Use write before the match recompute in LegacyDataUseService.java:187
A recompute failure reports the record as failed ("left unchanged") even though the Data Use write already committed. The retry then writes the Data Use a second time, producing a duplicate audit entry and a duplicate ES sync.
3. isAlreadyApplied skips the match recompute in LegacyDataUseService.java:153
An already-normalized record is marked skipped including its recompute. This contradicts the javadoc at line 150 ("a recompute always runs") and permanently strands the bad match rows from finding 1 on rerun.
4. access_management CTE diverges from Dataset.getAccessManagement in PersistedDataUseDAO.java:30
The CTE prefers any non-null canonical value — including blank or non-enum values — and never falls back to the legacy property. This produces false noncanonical reports whose proposed "fix" DataUsePrimaryValidator then rejects.
Consistency and efficiency
5. Recompute is per-dataset rather than per-DAR in LegacyDataUseService.java:196
A DAR shared across datasets is deleted and rebuilt once per dataset, and matchesRecomputed double-counts instead of counting distinct DARs.
6. findAllPersistedDataUse() runs three times per invocation — LegacyDataUseService.java:81
Called for the pre-report, the candidate list, and the post-report. This contradicts the DAO's "read once per run" note and makes reconcilesWith / leftClassificationsUnchanged false under concurrent dataset writes.
7. Normalize.approvalReference is never persisted or logged in LegacyDataUseDisposition.java:16
The traceability its javadoc promises does not exist.
8. accessManagementLabel() can emit values outside the OpenAPI enum in PersistedDataUseRow.java:16
It also uses default-locale toLowerCase() where the rest of the codebase uses Locale.ROOT.
dar_dataset carries drafts and archived requests. A draft has no submitted purpose to match against, and reprocessMatchesForPurpose cannot rebuild an archived one: findByReferenceId excludes it, so the delete would land and the insert would not. Both queries now filter to submitted, unarchived requests using the same predicate findByReferenceId applies, which keeps reachability and rebuildability in lockstep. The access-management CTE also accepted any non-null canonical value, so a blank or non-enum one blocked the legacy fallback that Dataset#getAccessManagement performs, reporting datasets as noncanonical on a value the validator would reject anyway. It now aggregates only values that parse as an AccessManagement, which also confines accessManagementLabel to the four values its schema declares. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reprocessing a DAR rebuilds matches for every dataset on it, so two candidates sharing one both deleted and rebuilt the same rows, and matchesRecomputed counted a DAR once per dataset rather than once. A run-scoped set of reprocessed reference ids fixes both, and recording each only after it succeeds keeps a retry scoped to what is still outstanding. The dataset population was also read three times per invocation. The candidate list now reuses the rows the before report is built from, leaving the two reads reconciliation actually needs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — findings 1 and 4 were both real, and 1 was worse than written up. All eight are now addressed across three commits. Already gone in cda4b1a, which landed just before you submitted: findings 2, 3, and 7 all lived in the disposition path. f01f7a4 — finding 1. Correct, and the archived case is data loss rather than churn: f01f7a4 — finding 4. Correct. d056a4c — finding 5. A run-scoped set of reprocessed reference ids: each DAR is rebuilt once and d056a4c — finding 6. Three reads down to two; the candidate list reuses the rows the Worth flagging on the production numbers I posted above: excluding drafts and archived requests can only shrink that 19-dataset population, so the bound still holds. The four new |
otchet-broad
left a comment
There was a problem hiding this comment.
Feedback from Claude:
1. A failed rebuild leaves a DAR with zero match rows
src/main/java/org/broadinstitute/consent/http/service/LegacyDataUseService.java:113 · correctness
reprocessMatchesForPurpose deletes before it inserts, each MatchDAO call auto-commits, and the failure is swallowed — yet the run reports the dataset as "left unchanged".
Dataset 16 has DAR ref-a. recomputeMatches calls matchService.reprocessMatchesForPurpose("ref-a"), which runs matchDAO.deleteRationalesByPurposeIds + deleteMatchesByPurposeId (each its own auto-commit statement on jdbi.onDemand), then insertMatches throws (deadlock, connection reset, constraint error). recomputeWithOneRetry catches, calls reprocessMatchesForPurpose("ref-a") again — deleting a second time — and it throws again. Outcome(true, 0, true) is returned; the response reports failed=1, failedDatasetIds=[16]. ref-a now has no match_entity and no match rationale rows at all.
LegacyDataUseRunReport.java:12 documents failed as "datasets left unchanged because every attempt failed" and legacyDataUseRecomputeMatches.yaml says "Restartable" and "rewrites only match rows" — both are false for this path. MatchDAO already extends Transactional<MatchDAO>, so wrapping the delete+insert in matchDAO.useTransaction was available and unused.
2. Exception messages are echoed to the client, leaking Other free text
src/main/java/org/broadinstitute/consent/http/resources/LegacyDataUseResource.java:41 · data leak
Both endpoints funnel exceptions into Resource#createExceptionResponse, which logs and returns e.getMessage() — the exact leak every other class in this PR is written to prevent.
Resource.java:249-268: createExceptionResponse calls logWarn("Returning error response to client: " + e.getMessage()) and builds new Error(e.getMessage(), 500) as the response body. The PR's stated invariant is the opposite:
PersistedDataUseClassifier.java:32— "Deliberately no cause or payload: the value can hold Other free text"LegacyDataUseService.java:105— "No message or cause: a failure raised while matching can quote the Other free text"LegacyDataUseRunReport.java:8— "failures carry a dataset id, never an exception message"
Any exception that escapes the service — a JDBI exception whose message quotes a row, or an NPE/IllegalArgumentException from PersistedDataUseClassification's EnumSet.copyOf on a malformed category list — is echoed verbatim to the admin client and into the application log, bypassing the redaction the rest of the diff enforces. The classes are careful; the boundary is not.
3. matchesRecomputed undercounts on partial success
LegacyDataUseService.java:126 · correctness
The recomputed set suppresses the already-done DARs from the retry's return value.
Dataset X's findDarReferenceIdsByDatasetId returns [a, b, c]. reprocessMatchesForPurpose succeeds for a and b (both added to recomputed), then throws for c. recomputeWithOneRetry retries: recomputeMatches skips a and b via the recomputed.contains guard, calls c, which succeeds → returns count=1. The report credits 1 DAR when 3 were actually rewritten.
If the retry of c throws too, Outcome(true, 0, true) credits 0 and lists X in failedDatasetIds, so the response asserts nothing changed while a's and b's match rows were replaced. LegacyDataUseRunReport.yaml documents matchesRecomputed as "Distinct Data Access Requests whose matches were recomputed", which the value does not equal.
4. MAX() picks the wrong access-management value on duplicate rows
src/main/java/org/broadinstitute/consent/http/db/PersistedDataUseDAO.java:34 · correctness
MAX(LOWER(TRIM(property_value))) picks the lexicographically greatest access-management value, whereas Dataset#getAccessManagement picks the first in property order — so a dataset with duplicate rows for one schema_property is graded against the wrong canonical shape.
A dataset has two dataset_property rows with schema_property='accessManagement', values 'controlled' and 'open' (PersistedDataUseDAOTest.canonicalPropertyWinsOverTheLegacyOne proves duplicate schema_property rows on one dataset are insertable — it inserts two properties both with property_key=1, so nothing prevents two rows for the same schema_property). The CTE returns MAX = 'open'; Dataset#getAccessManagement's parseAccessManagementProperty uses .findFirst() over the properties list and returns CONTROLLED.
With data_use = {"generalUse":true}: PersistedDataUseRow.isOpenAccess() is true → isCanonicalFor(true) requires State.NONE → the dataset is reported noncanonical over /noncanonical, while DataUsePrimaryValidator.validate(dataUse, CONTROLLED) accepts it. The class javadoc at lines 19-22 claims the fallback behaves "as Dataset#getAccessManagement does", so this is a contract violation, not just an edge case.
5. The access-management key match is case-sensitive
PersistedDataUseDAO.java:40 · correctness
dp.schema_property IN ('accessManagement', 'consentGroup.accessManagement') is case-sensitive, but Dataset#parseAccessManagementProperty matches with equalsIgnoreCase — a differently-cased stored key is invisible to the report.
A dataset_property row stored with schema_property='AccessManagement' (or 'consentGroup.AccessManagement') and property_value='open': Dataset.parseAccessManagementProperty uses schemaProperty.equalsIgnoreCase(property.getSchemaProperty()), so the running application resolves AccessManagement.OPEN and DataUsePrimaryValidator requires Shape.NONE. The DAO's exact-match IN clause skips the row, so access_management is NULL → accessManagementLabel() = "missing" and isOpenAccess() = false → isCanonicalFor(false) requires State.SINGLE.
A data_use of {} is then reported as noncanonical ("NONE under missing") when the app considers it canonical, and a data_use of {"generalUse":true} is reported canonical when the app rejects it. The cross-tab in countsByAccessManagement also drops the dataset into the wrong "missing" bucket.
6. processed counts datasets that rebuilt nothing
LegacyDataUseService.java:92 · correctness
processed counts datasets that raised no exception, not datasets whose matches were recomputed.
The PR's own test datasetWithNoDarsChangesNoMatches asserts processed=1 / matchesRecomputed=0 for dataset 19, whose findDarReferenceIdsByDatasetId returns []. The same happens in production two ways:
- A candidate whose only DAR was already rebuilt by an earlier candidate in the same run (all reference ids in
recomputed). - A TOCTOU window —
findAllPersistedDataUsereportsdarCount=1soneedsMatchRecompute()is true, then the DAR is archived beforefindDarReferenceIdsByDatasetIdruns, which now excludes it.
Both increment processed with zero DARs touched. LegacyDataUseRunReport.java:11 and LegacyDataUseRunReport.yaml both document processed as "Datasets whose matches were recomputed", so an operator reading processed=200 cannot tell how many datasets were actually rebuilt.
7. An unbounded full-population write runs synchronously on the request thread
LegacyDataUseResource.java:49 · robustness
A client or gateway timeout abandons the run mid-flight and discards the only record of what it did.
POST /api/datause/legacy/recomputeMatches does, in one request: findAllPersistedDataUse over every dataset row, then per abstaining candidate a findDarReferenceIdsByDatasetId query, then per reachable DAR a reprocessMatchesForPurpose (delete rationales + delete matches + findByReferenceId + a findDatasetById and a V5 match per dataset on the DAR + N inserts), then a second full findAllPersistedDataUse. With a few thousand noncanonical datasets this is tens of thousands of statements.
The load balancer returns 504 at its idle timeout; the thread keeps deleting and reinserting match rows, and the LegacyDataUseRunResult — including failedDatasetIds, the only thing that lets a rerun be scoped — is never delivered, so the operator cannot tell which datasets completed. This codebase already injects ExecutorService for bulk work (DatasetRegistrationService, DACAutomationRuleService, DacDashboardService); nothing here uses it, and no batching or limit parameter is offered.
8. isComplete(candidateCount) is a tautology
src/main/java/org/broadinstitute/consent/http/models/datause/LegacyDataUseRunReport.java:25 · correctness
It can never be false for a report produced by run(), so the "rerun decision" its javadoc says rests on it rests on nothing.
LegacyDataUseService.run (lines 86-97) increments exactly one of processed or failedDatasetIds per candidate, and constructs the report with failed = failedDatasetIds.size(). So processed + failed == candidates.size() by construction, for every input including a run where every dataset failed. The only way isComplete returns false is a caller passing a count that is not candidates.size() — which is what the tests do (emptyCandidateListIsComplete asserts isComplete(1) is false on an empty run). No production code calls it.
The javadoc "Every record accounted for, which is the check a rerun decision rests on" advertises a safety property the method cannot provide; an operator trusting it gets a green light regardless of outcome.
9. Six new public methods have no production caller
src/main/java/org/broadinstitute/consent/http/models/datause/LegacyDataUseRunResult.java:11 · dead code
Called only by their own tests — including the two the javadoc and OpenAPI present as the run's reconciliation guarantee:
leftClassificationsUnchanged()—LegacyDataUseRunResult.java:11reconcilesWith()—PersistedDataUseReport.java:78percentage()—PersistedDataUseReport.java:70isComplete()—LegacyDataUseRunReport.java:25findNoncanonicalRows()—LegacyDataUseService.java:44findRowsNeedingMatchRecompute()—LegacyDataUseService.java:56
Nothing in recomputeAbstainingMatches ever evaluates leftClassificationsUnchanged() or reconcilesWith() before returning, so the invariant legacyDataUseRecomputeMatches.yaml states ("a recompute-only run can be seen to have left them unchanged") is only checkable by a human eyeballing the JSON — the code that could enforce it exists and is bypassed. findRowsNeedingMatchRecompute is dead in a stricter sense: it is the exact filter recomputeAbstainingMatches inlines at line 69. findNoncanonicalRows is public and returns rows carrying the raw data_use (Other free text) even though only the redacted view is meant to leave the service.
10. data_use JSON is re-parsed roughly 8× per dataset per run
src/main/java/org/broadinstitute/consent/http/models/datause/PersistedDataUseRow.java:27 · efficiency
classification() re-runs a Gson parse of the raw data_use on every call; the codebase already has a memoizing parser (DataUseParser) and a shared Gson (GsonUtil.getInstance()).
One recomputeAbstainingMatches call over N datasets: PersistedDataUseReport.from parses each row 3× (countByLabelDescending at line 37, the groupingBy's countByLabelDescending at line 45, and !row.isCanonical() at line 33), and from() runs twice (before at line 65, report() at line 75) = 6 parses/row; plus row.classification().abstainsWhenMatched() at line 67 and needsMatchRecompute() at line 69 = 8 parses/row. findNoncanonicalViews adds 3 more per row (isCanonical, classification().label(), needsMatchRecompute()).
DataUseParser exists for exactly this column and memoizes via a ConcurrentMap keyed on the raw string; PersistedDataUseClassifier.java:16 instead builds a fresh, uncached GsonUtil.gsonBuilderWithAdapters().create() when GsonUtil.getInstance() already returns a shared instance. Caching the classification on the row (or reusing DataUseParser's cache) removes 7 of the 8 parses.
11. The second full-table read buys no real reconciliation evidence
LegacyDataUseService.java:75 · efficiency
The "after" report is built from a second full-table read even though the run provably writes no data_use, so it can only ever detect an unrelated concurrent edit — at the price of doubling the most expensive query in the endpoint.
recomputeAbstainingMatches derives before from the rows it already holds (line 65) and after from report() (line 75), which re-issues findAllPersistedDataUse over every dataset and re-parses every data_use 3×. Nothing between the two reads writes dataset.data_use — the only mutation is match_entity/match rationale rows. So before and after are identical unless a separate admin happens to PUT /api/dataset/{id}/datause during the window, in which case leftClassificationsUnchanged() reports false and blames the recompute for a change it did not make.
The PR's own test asserts verify(persistedDataUseDAO, times(2)).findAllPersistedDataUse(), locking the double read in. Reusing before for after (or checksumming instead of re-reading) gives the same guarantee at half the cost and without the false positive.
12. Selection filters are duplicated in the service and on the row
LegacyDataUseService.java:66 · simplification
Lines 66-70 build abstaining = rows.filter(row.classification().abstainsWhenMatched()) then candidates = abstaining.filter(needsMatchRecompute), while findRowsNeedingMatchRecompute (line 56) is already exactly the second filter and needsMatchRecompute (PersistedDataUseRow.java:37) is already abstainsWhenMatched() && darCount > 0.
The only thing the local abstaining list buys is the log line's abstaining.size() - candidates.size() count, obtainable as a single partitioningBy or by counting darCount==0 among candidates' complement. If the reachability rule in needsMatchRecompute changes (say, to exclude datasets with no DAC), the inlined pair here silently keeps the old two-step meaning and the log's "left alone" figure stops matching. Likewise findNoncanonicalRows (line 45) duplicates PersistedDataUseReport.from's own !row.isCanonical() filter (PersistedDataUseReport.java:33).
13. A raw DAO is passed as a service constructor parameter
LegacyDataUseService.java:34 · convention
docs/ai/CLAUDE.md ("DAO instantiation") states: "All Jdbi SQL object DAOs (interfaces in org.broadinstitute.consent.http.db) must be instantiated inside the service constructor via jdbi.onDemand(XxxDAO.class). Never pass a raw DAO as a constructor parameter to a service."
Line 34 is LegacyDataUseService(PersistedDataUseDAO persistedDataUseDAO, MatchService matchService) — PersistedDataUseDAO is an interface in org.broadinstitute.consent.http.db, passed as a constructor parameter. The root CLAUDE.md pulls this file into scope ("For guidelines on specific components, review the documents in the docs/ai/ directory"). Only one other service in the package does anything similar (ElasticSearchCapabilityService); the repo's normal escape hatch for testability is @VisibleForTesting (used in DACAutomationRuleService, DaaService, DarCollectionService) or mocking Jdbi.onDemand.
14. PersistedDataUseReport stores its maps without a defensive copy
src/main/java/org/broadinstitute/consent/http/models/datause/PersistedDataUseReport.java:22 · correctness
Unlike its sibling LegacyDataUseRunReport, which copies its list, a directly-constructed report is mutable through the caller's reference.
LegacyDataUseRunReport.java:20 adds a compact constructor doing failedDatasetIds = List.copyOf(failedDatasetIds); PersistedDataUseReport has no compact constructor, so new PersistedDataUseReport(1, mutableMap, mutableNestedMap, 0, 0, 0) — the shape LegacyDataUseResourceTest.runResult already uses — keeps the caller's map live. Any future caller (or a caller mutating the map after construction) changes a record that leftClassificationsUnchanged() compares by equals(), so the pre/post comparison can silently agree or disagree based on post-construction mutation.
Note the copy must not be Map.copyOf for the values produced by from(), which are intentionally insertion-ordered (see the comment at line 65); an unmodifiable LinkedHashMap/TreeMap copy is needed.
reprocessMatchesForPurpose deleted the rationales and the matches, then inserted the replacements, with each DAO call auto-committing on its own. An insert that failed - a deadlock, a reset connection, a constraint - left the purpose with no match rows at all, and the caller with no way to tell that from a no-op. MatchDAO has extended Transactional all along. The rebuild is now computed before anything is deleted, and the delete and the insert share one transaction, so a failure leaves the existing rows in place. This is the path the DAR update and the match resource take too, not only the legacy recompute added here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dataset#parseAccessManagementProperty compares the schema property with equalsIgnoreCase; the CTE matched it exactly. A row stored as AccessManagement resolved to OPEN in the running application while the report read the dataset as having none, so a shape the validator accepts was reported noncanonical, and the cross-tab counted the dataset under missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The report described work the run had not necessarily done. matchesRecomputed came from whatever the last attempt returned, and a retry skips the DARs the first attempt already rebuilt, so a partially successful dataset undercounted and a dataset that failed on its last DAR reported zero while its earlier ones had been replaced. The count now comes from the growth of the run's rebuilt set, and is credited whether or not the dataset went on to fail. Reverting either half fails the two new tests. processed counted every dataset that raised no exception, including one whose DARs a previous candidate had already rebuilt and one that had none left to reach - documented as "datasets whose matches were recomputed". It now says what it counts, and unchanged reports the subset that rebuilt nothing, which processed alone could not distinguish. isComplete could not return false for any report run() produces: it compared processed + failed against a count that is their sum by construction. Deleted, with percentage, reconcilesWith and findRowsNeedingMatchRecompute, none of which had a production caller. leftClassificationsUnchanged had none either, which left the invariant the spec advertises checkable only by eye; the run evaluates it now and warns when a recompute-only run moved a record. findNoncanonicalRows is inlined into findNoncanonicalViews, so rows carrying the raw value no longer leave the service at all. Each row's data_use was parsed about eight times per run: three times per report, twice per report because the report is built twice, and twice more selecting candidates. The report classifies each row once and asks that parse every question, and candidate selection is one partition over one parse, which also removes the duplicated two-step abstaining filter. PersistedDataUseReport now copies the maps it is given, as its sibling copies its list - order-preserving copies, since the counts are deliberately ordered. The DAO constructor keeps the repo's @VisibleForTesting marker. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — ten of the fourteen are fixed across three commits. Four are answered below rather than changed, and two of the write-ups do not hold as described.
Two corrections.
The mechanism named in #2 cannot fire. #2, the leak boundary. The observation itself stands: #4, #7, the synchronous run. True as written, and the numbers bound it: the audit found 19 datasets both on an abstaining shape and reachable through a DAR, across 52 mapped persisted matches. That is seconds of work, not the tens of thousands of statements the finding sizes for. The part of it that survives is real though — if the request does time out, the #11, the second read. Keeping it. Your false-positive point is the good half of the argument, and it is now handled: the run evaluates
|
Both are paths the last two commits added and nothing exercised: the warning raised when the classification counts move under a recompute that writes no Data Use, and the rebuild of a purpose whose DAR is archived or gone, which must still clear the stale rows and insert nothing. Neither is coverage for its own sake - the first pins that a concurrent edit is reported rather than silently sitting in the response, and the second is the case that made the delete-then-rebuild ordering matter in the first place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The retry catches Exception, which is broad enough to treat a programming error as infrastructure worth retrying, and the log said only that the dataset failed. Both attempts now name the exception's class. The class, not the message: the message is what can quote the Other free text, and that redaction is the reason the log was bare in the first place. A test pins each line separately, and fails if either stops naming the class or starts carrying the message. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|



Addresses
DT-3863. Its only dependency, DT-3864, is already merged. Scoped by the DT-3861 audit.
Security risk: low.
Summary
Recomputes the persisted matches affected by the legacy noncanonical Data Use population. One dataset additionally needs its Data Use corrected; that needs a domain decision and is not done here.
What this PR does
Two admin-only endpoints and the driver behind them:
GET /api/datause/legacy/noncanonical— the datasets whose shape the canonical validator would reject, by id and classification label.POST /api/datause/legacy/recomputeMatches— recomputes stored matches under V5, and returns the classification counts either side of the run so a recompute-only run can be seen to have changed no stored Data Use. Rewrites match rows and rationales only, never elections, final votes, or historical automation votes.Why the recompute is safe to merge now:
DataUsePrimaryValidatoraccepts any single primary, Other included, so the 566SINGLE(OTHER)and 509 open-accessNONEdatasets are valid —DataUseMatcherV5merely abstains on them. Their stored matches predate that policy and need recomputing; their data does not need changing, so no approved disposition is involved.Restartable: the run writes no stored Data Use, so a rerun simply recomputes the same matches, and the response lists the datasets that failed so a rerun can be scoped to them.
What needs approval
Exactly one dataset is invalid —
MULTIPLE(HMB+OTHER)under controlled access, the only one of 5,811 the audit found. DT-3861's review labelled it Mixed and recorded that mixed cases must not be normalized without an explicit domain-approved disposition.@jlaw-codes @ncalvanese1 — as Data Use domain owners, could one of you approve on DT-3863 either:
DataUsevalue, with where the approval is recorded.Recommendation: keep
hmbResearchas the single primary and move the Other text tosecondaryOther, givingSINGLE(HMB). The reviewer labelled it Mixed because the text reads as a use restriction rather than a primary category, and HMB is already asserted on the record — so this invents no category the reviewer did not choose. This is a proposal, not a decision taken.Merging before that approval is intentional: until a disposition exists V5 abstains on the record, so the DAC sees ABSTAIN with a manual-review rationale rather than an automated decision. Nothing in this PR is blocked on it.
After approval
No code change. Four calls:
GET /api/datause/legacy/noncanonical— the dataset id.PUT /api/dataset/{id}/datause— applies the approved value; validates, translates, audits, syncs the index.POST /api/datause/legacy/recomputeMatches— the response carries its own before/after reconciliation.GET /api/datause/legacy/noncanonical— empty.Testing
Full suite: 4626 tests, 0 failures, 1 pre-existing skip. Spotless clean, no
lenient()stubbing, synthetic data only.PersistedDataUseDAOTestruns against the real schema. New classes are at 100% instruction and branch coverage.