fix(audit): berth rules/recommender — M4 (bundle-wide status), M5 (berth_unlinked target), M20/L27 (interest_berths invariant + cross-port guard), L3 (recommender stage-scale), L4 (dead branch)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:59:12 +02:00
parent 4084029962
commit 70bf26aea1
5 changed files with 163 additions and 58 deletions

View File

@@ -418,17 +418,24 @@ export async function removeInterestBerth(
if (!interestRow || !berthRow) {
throw new NotFoundError('interest or berth');
}
await db
.delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
// G-C4: fire the berth_unlinked berth-rule. Default mode is 'off' so this
// is a silent no-op unless an admin opted in via system_settings.berth_rules.
// Dynamic import avoids a static cycle: berth-rules-engine imports this file
// (getPrimaryBerth). meta is optional so older callers that haven't been
// threaded through can still call this without triggering the rule.
//
// Audit M5: evaluate BEFORE the delete and pass the just-unlinked `berthId`
// as an explicit target override. Firing after the delete would let the rule
// re-resolve its target via `getPrimaryBerth`, which — with the row already
// gone — points at a DIFFERENT still-linked berth and would corrupt that
// unrelated berth's status if an admin enabled auto/suggest mode.
if (meta) {
const { evaluateRule } = await import('@/lib/services/berth-rules-engine');
void evaluateRule('berth_unlinked', interestId, portId, meta);
await evaluateRule('berth_unlinked', interestId, portId, meta, berthId);
}
await db
.delete(interestBerths)
.where(and(eq(interestBerths.interestId, interestId), eq(interestBerths.berthId, berthId)));
}