relationships: refuse links that cannot both be true, and report the ones already stored
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

Prod grew rows saying both "PC controls 2005" and "2005 controls PC", and a CMM
PC showing "<- controls from CMM4" beside its own outgoing link. Only one
direction can be true: a PC drives a machine, never the reverse.

Nothing stopped it. The duplicate check was keyed on (source, target, type), so
the inverse inserted cleanly, and the Add Relationship dialog offers an incoming
direction that writes exactly that. The legacy import stores controls the wrong
way round as well. Directional creates now refuse the reverse with a 409 naming
the row that already holds it, and refuse self-links, which render as a
duplicate on the asset's own page and mean nothing. Symmetric types are exempt:
Dualpath stores both directions on purpose and the card collapses them. The
propagation fan-out got the same guard so a rail meant to spread one direction
across sibling bays cannot manufacture a pair.

fix-controls-direction only matched source assettype 'machine', so every
measuring_tool, printer and network_device row it was written to clean survived
it - which is why running it would never have fixed the CMM. It now matches any
non-computer controlled BY a computer.

New `flask relationships audit` reports what is already stored: reciprocal
pairs, self-links, and PCs controlling several assets of one type. Read-only,
and it prints each row's label because that usually names the writer outright -
collector:* means this code made it, anything else means a person or the import
did. That distinction decides the fix for duplicate device assets, which is not
in this commit: the collector keys idempotency on its own label, so a device
somebody created by hand is invisible to it and it mints another, and the
adoption rule needs the audit run against prod before it can be written.

Two false positives were found writing it, against the dev database, and both
would have made the report useless. A self-link is its own inverse, so it was
counted as a reciprocal pair AND printed twice. And Dualpath siblings looked
like duplicate devices - a dual-bay machine is one physical machine with one
controller and controls is propagated to both bays deliberately. That was 30 of
32 findings, consecutive bay numbers pair by pair.
This commit is contained in:
cproudlock
2026-08-13 12:25:24 -04:00
parent 6cdbea449a
commit e67fe47fe2
3 changed files with 349 additions and 7 deletions

View File

@@ -610,6 +610,12 @@ def _add_propagated(sourceid, targetid, rel, created):
return
if _relationship_exists(sourceid, targetid, rel.relationshiptypeid):
return
# Never fan out INTO an existing inverse. The same one-way rule the create
# path enforces: if target-T->source is already stored, adding source-T->
# target would manufacture a reciprocal pair that no user asked for, on a
# rail that is meant to spread one direction across sibling bays.
if _relationship_exists(targetid, sourceid, rel.relationshiptypeid):
return
newrel = AssetRelationship(
sourceassetid=sourceid,
targetassetid=targetid,
@@ -754,6 +760,14 @@ def create_asset_relationship():
if not db.session.get(RelationshipType, type_id):
return error_response(ErrorCodes.NOT_FOUND, f'Relationship type {type_id} not found', http_code=404)
# An asset cannot relate to itself. It renders as a duplicate row on that
# asset's own page (the card lists both ends) and means nothing.
if source_id == target_id:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'An asset cannot be related to itself'
)
# Check for duplicate relationship
existing = AssetRelationship.query.filter_by(
sourceassetid=source_id,
@@ -768,6 +782,32 @@ def create_asset_relationship():
http_code=409
)
# And it cannot relate BOTH WAYS on a directional type. Only one direction
# can be true - a PC drives a machine, never the reverse - but the check
# above is keyed on (source, target, type), so the inverse used to insert
# cleanly and both rows then rendered on both asset pages. The dialog offers
# an `incoming` direction, so this is reachable by hand, and the legacy
# import stores controls the wrong way round.
#
# Symmetric types are exempt: they store both directions ON PURPOSE and the
# card collapses them to one entry per peer.
reltype = db.session.get(RelationshipType, type_id)
if reltype is not None and reltype.isdirectional:
inverse = AssetRelationship.query.filter_by(
sourceassetid=target_id,
targetassetid=source_id,
relationshiptypeid=type_id
).first()
if inverse is not None:
return error_response(
ErrorCodes.CONFLICT,
f"The reverse of this relationship already exists "
f"(relationship {inverse.relationshipid}). A "
f"'{reltype.relationshiptype}' link points one way only - "
f"remove the existing one first if the direction is wrong.",
http_code=409
)
rel = AssetRelationship(
sourceassetid=source_id,
targetassetid=target_id,