Multi Model Consistency

Torsten Syma edited this page Jan 16, 2019 · 4 revisions

Transitive Change Propagation

The Vitruvius framework allows the combination of Vitruvius-applications through the transitive execution of the model transformations. This is achieved by transitively propagating changes that are produced by transformations. Ideally this can be used to achieve multi-model consistency between more than two domains, without having to implement direct transformations between all involved domains, or having to implement a multiary transformation. However, there are problems associated with transitive change propagation.

We want to provide some general guidelines on how to operationalize an individual transformation so that it can be (better) used in a transitive change propagation scenario, independent of the other transformations. To explain why problems occur and how they can be avoided, we first have to explore how the transitive change propagation works (in the Vitruvius framework). Throughout this exploration we will make the following conclusions/claims:

  1. We have to prevent element-creation conflicts.
  2. We have to accept that attribute conflicts can be intentional.
  3. We should avoid transitional states.
  4. We should prevent the propagation of deprecated information.

In sections Element-Existence Check and Event-Validity Check, we provide solution patterns for the implementation of transformations using the Reactions Language, to concretize rules 1 and 4.
These patterns do not solve all problems of transitive change propagation, but they appear to solve all causes of non-terminating change propagation loops. This leaves less severe problems of information loss or inconsistency, which are associated with change granularity and transformation execution order (not explained here). Some of those can be circumvented by reducing the change increments that are synchronized.

Also, some transformations can be incompatible, because they try to enforce contradictory consistency specifications, in which case, the consistency preservation may be unachievable for some sub-domains. Incompatibility cannot be solved through "better" operationalization of the transformations, only through correct specification of the global consistency goal and selection of transformations that implement compatible local consistency constraints.

Change Propagation in Vitruvius

Model transformations developed with the Reactions Language are always concerned with two meta-models (domains). One domain is monitored for changes by the Vitruvius framework. This is the source domain. The other domain is the target for changes produced by the developed transformations, as a response to the changes in the source domain. This other domain is the target domain. A transformation propagates changes from its source domain to its target domain (although the changes are obviously transformed so that they adhere to the target meta-model definition).

A single Vitruv-application can employ multiple such transformations. The simplest use-case is an application that employs two opposite facing transformations to achieve a composed, quasi-bidirectional transformation between two domains.

The default behavior of the Vitruvius framework only triggers transformations based on user-introduced changes. This means that a model change cs performed on a domain ds that functions as the source domain of a transformation T, employed by the Vitruv-application, triggers the execution of said transformation and produces new changes ct in the target domain dt. These new changes ct do not trigger other transformations, for which dt functions as the source domain, because they were the consequence of a transformation.

Why is this the default behavior for Vitruv-applications?
Because of the assumption that an application only manages two domains and only one domain is changed at a time. In that case, even if the application employs a bidirectional transformation, composed of a forward and a backward transformation, the domains can be brought to consistency by a single transformation execution, as long as each directed transformation restores consistency in response to changes of its source domain. It is not necessary to execute the backward transformation in response to the changes made by the forward transformation, if both domains are already consistent.

What is transitive change propagation?

In the general, we might want to combine transformations between more than just two domains, ideally those transformations could even be independently developed. The expected benefit is that if we combine a transformation T-AB between domains A and B with a transformation T-BC between the domains B and C, then we can achieve consistency between the domains A and C without first having to define an explicit transformation. The transitive execution of both transformations (T-BC after T-AB, and the other way around) results in an implicitly defined transformation T-AC.
In this case, it is necessary that model changes produced by a transformation can trigger additional transformations, and thereby propagate trough one domain to another. Because each transformation propagates a change from domain to domain, and the transformations are transitively executed we call this transitive change propagation.

How can transitive change propagation be activated in Vitruvius?

In the Vitruvius framework, the transitive change propagation can be enabled on a per-domain basis. This is done by calling:

  • new ...DomainProvider().getDomain().enableTransitiveChangePropagation();

A good place to do so is the initialization of the concrete Vitruv-application class, as can be seen in the code example below (lines 13-15, 31-35).

package wiki.examples.uml2java;

import java.util.HashSet;
import java.util.Set;

import tools.vitruv.domains.java.JavaDomainProvider;
import tools.vitruv.domains.uml.UmlDomainProvider;
import tools.vitruv.framework.applications.AbstractVitruvApplication; 
import tools.vitruv.framework.change.processing.ChangePropagationSpecification;

public class Uml2JavaExampleApplication extends AbstractVitruvApplication {

	public Uml2JavaExampleApplication() {
		patchDomains();
	}
	
	@Override
	public Set<ChangePropagationSpecification> getChangePropagationSpecifications() {
		Set<ChangePropagationSpecification> specs = new HashSet<ChangePropagationSpecification>();
		// specs.add(new Uml2JavaChangePropagationSpecification());
		// specs.add(new Java2UmlChangePropagationSpecification());
		// ...
		return specs;
	}

	@Override
	public String getName() {
		return "UML <-> Java Application Example";
	}
	
	private void patchDomains() {
		// these calls enable transitive change propagation for the domains relevant to this VitruvApplication
		new UmlDomainProvider().getDomain().enableTransitiveChangePropagation();
		new JavaDomainProvider().getDomain().enableTransitiveChangePropagation();
	}

}

It ensures that the transitive change propagation is enabled for the relevant domains before changes are monitored.

Transformation Networks and Problems

Transitive change propagation allows us to combine multiple (binary) transformation and to form a transformation network that restores consistency for all connected domains after a set of initial user changes. Ideally, each transformation could be individually developed and re-used based on the required set of domains. However, in general, a number of problems emerge when combining transformations in such a way.

Here is a (possibly incomplete) list of factors that interact and influence how a transformation network operates in a concrete application scenario:

  • system specification
    • the transformation network topology
      • tree, mesh or fully-connected
    • the consistency specifications of the individual transformations
  • the operationalization of the transformations
  • transformation engine properties
    • change granularity
    • when are the individual changes applied
      • when they occur or when they are resolved
    • in which order are the changes resolved
      • resolve transitive changes before or after the initial changes
    • transformation (rule) execution order
      • Which transformation is executed if a change can trigger more than one?
    • what can be stored in the provided trace model
  • user behavior
    • the change sequence can be arbitrary
    • the change increment can be arbitrarily large

We have to assume that the transformation specifications are sensible and compatible to each other, and that the transformations are free of trivial implementation errors of said specifications. Without this assumption, we can not hope to get a correctly operating transformation network, in the sense that it actually restores consistency for all involved domains, for any change sequence, because we cannot fix a contradictory specification so long as we correctly implement it. Making sure that the consistency specifications of the combined transformations are compatible to each other is the task of whoever composes the transformation network (or designs the consistency specification for a new transformation).

Now, given this assumption, we can focus on how to operationalize transformations, depending on the properties of the transformation engine, so that they are fit to be used in a transitive change propagation scenario. Ideally, this approach also informs what properties the transformation engine should have/provide.

We do not go into detailed explanation of each aspect here, or explain all ways their interactions can fail. Instead, we explain change conflicts and the following deprecated change events, because we can provide solution patterns, which seem to be generally applicable, for both problems.
There are additional failure potentials that are linked to change granularity and the transformation execution order, for which we do not have general solutions.

Change Conflicts

Change conflicts can arise whenever multiple changes affect the same model (element) and their effects somehow conflict with each other. We can differentiate two sub-categories of change conflicts:

  • attribute change conflicts: Two changes that affect the model in such a way that the effect of one change invalidates the effects of the other change.
    • For example:
      • set(uClass.name,"A"), set(uClass.name,"B")
      • insert(uClass.attributes,newAtt), remove(uClass.attributes,newAtt)
  • element-creation change conflicts: Two changes that both attempt to create the (semantically) identical element. However, because each created element is its own instance, this results in the duplication or replacement of the intended element, depending on the container in which the new element is stored (single-valued or list feature).
    • Examples for this are difficult to describe, because, from a user view, each manual element creation is intentional. The "unintentionallity" stems from an incorrect correspondence mapping that claims that would two distinct elements in the same domain are actually the same element.
    • One example could be the duplication of a java::Class-definition in the same package as the original.

There are three ways that changes can accumulate for a single model:

  • The user can perform any type of (valid) changes and can initiate when the set of changes is synchronized. As a result, the user can also perform multiple changes that conflict. So that we can make claims about correct transformation behavior, we have to assume a user that acts intentionally and that the user-changes should be preserved. This means that, for example, attribute overwrites are intentional and a consequence of a changed decision. Furthermore, any apparent element duplication (the elements seem identical based on their attribute) is intentional and therefore the elements cannot be semantically identical, which also means that the user cannot perform element-creation conflicts.
  • The implementation of a transformation (rule) can produce multiple changes in its target model. These can conflict for example, when a transformation rule creates temporary target model states, which it subsequently overwrites with the intentional target model state. Although this may be suboptimal behavior, it is not inherently faulty and it would not be a problem in the non-transitive change propagation scenario, because the changes are still intentional, intentionally ordered and they do not have to be further processed.
  • As a consequence of transitive change propagation there can exist multiple transformation paths, along which a change can be propagated and reach the same domain. Let us call such paths confluent. (In the literature, "confluence" also implies that the change that reaches the domain is identical along all possible paths, which is generally not the case.)
    If the transformation specifications are compatible to each other, then we can expect a single source change to produce equivalent target changes along all paths. This means that attribute changes would simply converge, because the second change that tries to set the same value no longer has any effect, and attribute change conflicts are unproblematic. However, element-creation cannot converge, because each element-creation change creates its own unique instance. Therefore element-creation conflicts are a problem and have to be avoided.

Individually, only the introduction of transitive change propagation and confluent propagation paths has an inherent chance of unintentional element-creation conflicts. Attribute conflicts are either non-conflicts, because they should set the same values, or they are intentional by the user or the transformation developer. The main problems arise, because through transitive change propagation these effects now interact. Through the confluent change propagation paths, now sets of changes can interact and conflict with each other. Transformation produced changes, as a reaction to an early user-change, can be propagated back to the source domain and conflict with later user-changes. Transformation produced changes can interfere with transitional states of other transformations.
This increases the change of element-creation conflicts and it now introduces risk to attribute conflicts, because intentional attribute changes can lead to the loss of information and interfere with each other.

From this observation about change conflicts, we can derive following conclusions:

  1. We have to prevent element-creation conflicts.
  2. We have to accept that attribute conflicts can be intentional.
  3. We should avoid transitional states.

Element-creation conflicts can be prevented by "simply" checking, whether or not an element might already exist, before any element creation. We discuss how this can be (systematically) realized in the Element-Existence Check section.

Deprecated Change Events

When an attribute changes conflict occurs and both changes are applied to the affected model (element) before the changes are resolved by the transformation engine, then the model state at the time when the first change is being resolved may not match the change description. The first change describes deprecated information and propagating this deprecated information may cause problems and should therefore be avoided.

Let us first differentiate a few concepts:

  • A change, in the abstract sense, is a delta between two model states.
  • A change is applied when its model delta is applied to the affected model and the effects are afterwards visible and retrievable from the affected model.
  • A change is being resolved when the transformation engine processes the change and triggers the transformations necessary to restore consistency.

It is important to note that apply and resolve do not have to occur together if multiple changes are pending.

How does this work in the Vitruvius framework (for transformation changes*)?
The framework monitors the model domains to detect changes. The detected change events are recorded. The recorded change events are then resolved by the transformation engine to restore consistency. This means that all pending changes are already applied before the change events are being resolved.

Batch-application of changes before they are resolved has a big advantage, because it allows transformation rules to see the effects of later, unresolved changes and avoid producing new changes that may be in conflict with those that are still unresolved. But it comes with the down-side, that the change effects described by the recorded change events may be deprecated, at the time the change event is being resolved. We call these deprecated change events. Take for example two name changes c1=set(uClass.name,"A"), c2=set(uClass.name,"B"). If c2 is applied after c1 but before c1 is resolved, then, on resolving c1, the described effect is deprecated, because uClass.name has been overwritten and is already set to "B".

If we propagate deprecated information, we run the risk of replicating the same change conflicts in the target domain. This may ultimately lead to a ping-pong between domains and produce a non-terminating change propagation loop.

  1. We should prevent the propagation of deprecated information.

We discuss how this can be realized in the Event-Validity Check section.

(* Vitruvius handles user-changes slightly different, from transformation produced changes, because the consistency preservation framework works on internal copies of the models. User-changes are recorded and then replayed on an internal copy of the model, applying and resolving each change one at a time. It has the advantage, that deprecated change events cannot occur in the first transformation step, which is the only executed step for the non-transitive change propagation. But on the down-side, reactions cannot adapt their output according to still unresolved changes, which makes it impossible to avoid element-creation conflicts, with pending user-changes, in the transitive change propagation scenario.)

Element-Existence Check

Element-existence checks are a very simple pattern to prevent element-creation change conflicts. Because each created element is unique, changes that attempt to create the same element cannot simply converge to a conform model state. Instead, either the initially created element is replaced, which may lead to information loss, or the element is duplicated, which can be the cause for inconsistency. Because element-creation conflicts cannot be fixed after they occurred, we have to prevent them.

Secondly, because we do not make limitations about the consistency specification and transformation network topology, changes can potentially reach the target model along multiple transformation paths. This also means that if we think we have to create an element, it may have already been created along another transformation path or directly by the user. Therefore, before any element creation, we have to check whether the target element might already exist. We call this check the existence check.

  • (User -> UML)
    • c1=create(uClassA)
  • (c1 -> Java)
    • c2=create(jClassA)
    • uClassA corresponds to jClassA
  • (c2 -> UML) - without existence-check
    • c3=create(uClassA') a duplication occurs
  • (c2 -> UML) - with existence-check
    • uClassA = retrieve ... correpondence for jClassA
    • because uClassA exists, the transformation rule can stop
    • no new change, the duplication is avoided

To determine whether or not an element already exists, we can make use of the trace model, the target model state, and the consistency constraints on the features of the target element. This gives us following steps:

  • Direct Retrieval - The correspondences of the correspondence model may be ably to identify the target element , if it is a 1-to-1 correspondence. Otherwise we may still be able to retrieve a set of candidates.
    But, even if the the target element exists, the correspondence may not have been created yet. Therefore the absence of the target element in the direct candidate set, does not imply the absence of the target element.
  • Context Retrieval - The consistency constraint that require the existence of the target element may provide the target model context where the element could exist. From that target context, we can retrieve a set of candidates that may be the target element.
    If a target context can be uniquely identified and the target element exists, then it has to be contained in the context candidate set.
  • Filter Candidates - If we can identify a candidate set, we can try to limit the number of candidates by filtering them according to key features. Ideally, these key features are uniquely identifying, however, this may not always be achievable. Often simple key-features like the target elements type and its name (if a name feature exist) are sufficient.
  • Limit through Intersection - We can try to limit the candidate sets, by calculating their intersection. However, we have to notice, that the intersection can help to identify the target element if it is in both, the direct and context, candidate set, but it cannot prove the absence of the target element.
  • User Disambiguation - If, after the filtering and limiting of the candidates, there are still multiple candidates left, then it may be necessary to request disambiguation of the candidates, or guidance how to handle the situation from the user.

Depending on the specific consistency mapping that has to be managed, it may be sufficient to perform only direct and context retrieval, or it can be necessary to use all means. It is important to start with the most reliable and restrictive method to retrieve the target element. If the element is not found that way, we can drop to the next, less restrictive method. This gives us the following order:

  1. intersection of direct and context candidates
  2. directly retrieved candidates
  3. context retrieved candidates

If for any candidate set there are:

  • multiple candidates -> we can initiate user disambiguation
  • exactly one candidate -> we have found the target element
  • no candidates -> move on to the next, less restrictive candidate set

If even the context retrieved candidate set is empty, then we can safely assume the element does not yet exist and create it.

The code example below shows how this may be realized for a Reactions language transformation.

import org.emftext.language.java.members.Field

import "http://www.eclipse.org/uml2/5.0.0/UML" as uml
import "http://www.emftext.org/java" as java

reactions: existenceCheckExample
in reaction to changes in UML
execute actions in Java

reaction UmlAttributeCreatedInClass {
    after element uml::Property created and inserted in uml::Class[ownedAttribute]
    call createJavaAttributeInClassIfNecessary(affectedEObject, newValue)
}

// This routine performs an existence-check before creating a new java::Attribute
routine createJavaAttributeInClassIfNecessary(uml::Class umlClass, uml::Property umlAttribute) {
    match {
    	// does the context match? -- only if the uml::Class needs to be synchronized with a java::Class
        val javaClass = retrieve java::ConcreteClassifier corresponding to umlClass
        // does a corresponding attribute already exist (and is it registered in the correspondence model)? 
        // -- if yes, then the creation of a new one is unnecessary
        require absence of java::Field corresponding to umlAttribute
    }
    action {
        call {
        	// check the context if an java attribute exists that matches the uml attribute,
        	// which is not yet registered in the correspondence model
        	val javaAttributeCandidates = javaClass.members
        	 	// filter by (unique) key-features that can help identify a correspondence
        		.filter(Field) // filter by type
        		.filter[it.name == umlAttribute.name] // filter by name
        		// depending on the consistency specification, additional constraints can be necessary
        		.toList
        		
        	if (javaAttributeCandidates.size == 0){
        		// no matching java attribute exists yet, so we have to create one
        		createJavaAttributeInClass(umlClass, umlAttribute)
        	}
        	else if (javaAttributeCandidates.size == 1){
        		// exactly one matching java attribute exists, 
        		// but the correspondence is not yet registered in the correspondence model
        		addAttributeCorrespondence(umlAttribute, javaAttributeCandidates.head)
        		// (additionally) it may be necessary to ensure that the non-key features are also consistent
        	}
        	else if (javaAttributeCandidates.size > 1){
        		// notify the user about the problem and request disambiguation or guidance
        	}
        }
    }
}

routine createJavaAttributeInClass(uml::Class umlClass, uml::Property umlAttribute) {
    match {
        val javaClass = retrieve java::ConcreteClassifier corresponding to umlClass
        require absence of java::Field corresponding to umlAttribute
    }
    action {
        val javaAttribute = create java::Field and initialize {
            javaAttribute.name = umlAttribute.name;
            // javaAttribute.visibility = matching java visibility modifier for umlAttribute.visibility
            // javaAttribute.type = matching java::TypeReference for umlAttribute.type
            // ... 
        }
        update javaClass {
            javaClass.members += javaAttribute
        }
        call addAttributeCorrespondence(umlAttribute, javaAttribute)
    }
}

// useful as a separate routine, because then it can be called from within an execute- or call-block 
routine addAttributeCorrespondence(uml::Property umlAttribute, java::Field javaAttribute){
	match {
		require absence of uml::Property corresponding to javaAttribute
		require absence of java::Field corresponding to umlAttribute
	}
	action{
		add correspondence between umlAttribute and javaAttribute
	}
}

Event-Validity Check

Attribute change conflicts can be intentional and we therefore cannot prevent them. Instead, we have to handle the subsequent deprecated change events. Take for example following change sequence:

  • initial user changes (user -> UML):
    • c1 = set(uClass.name,"A"), c2 = set(uClass.name,"B")
    • neither change is resolved, but both are applied
    • the model state is uClass.name == "B"
  • resolving c1 (UML -> Java):
    • the effect described by c1 claims uClass.name == "A"
    • this is no longer true, because c2 overwrote c1
    • the change event c1 is deprecated
    • c1 should not be propagated to avoid further change conflicts
  • resolving c2 (UML -> Java):
    • the described effects matches the model state
    • c2 is valid and should be propagated

We can determine whether or not a change event is deprecated by comparing the current model state to the change effect described by the change event. In most cases this can be done with generic checks depending on the change event type, as can be seen in the table below.

event type isDeprecated(event)
set(e.f, newVal) e.f != newVal
replace(e.f, oldVal, newVal) (e.f != newVal) || (e.f == oldVal)
insert(e.list, newVal) !list.contains(newVal)
remove(e.list, oldVal) list.contains(oldVal)

If the event is deprecated, then its effect is no longer valid and we should avoiding propagating the deprecated information. So the validty check is the negation of the isDeprecated(...) predicate.

In some cases, the checks listed above are not sufficient, because to values are not identical (because they are different instances) but still represent the same semantics. In such cases, it may be necessary to reformulate the predicates based on equality checks.
Another peculiarity is that a replace change can be partially deprecated, because it states two effects that can be individually deprecated. As a result, it may be necessary to react to the removal-part of the replace event, even though the set/insert-part is deprecated.

The code example below shows how this may be realized for a Reactions language transformation.

import "http://www.eclipse.org/uml2/5.0.0/UML" as uml
import "http://www.emftext.org/java" as java

reactions: validityCheckExample
in reaction to changes in UML
execute actions in Java

// Change-events can be (partially) deprecated as a result of later changes (that occur, and are applied, 
// before the initial change event is processed and its repair-routines are triggered):
// 		a) the old value can be re-instated/re-inserted in later changes
// 		b) the new value can be overwritten/removed by later changes 
reaction UmlAttributeRenamed_withEventValidityCheck {
	after attribute replaced at uml::Property[name]
	call {
		val umlProperty = affectedEObject
		val actualName = umlProperty.name // retrieves the actual model state
		
		val oldValue_isDeprecated = (oldValue === actualName) // true, if the old name has been reinstated
		val newValue_isDeprecated = (newValue !== actualName) // true, if the new name has been overwritten
		
		if(!oldValue_isDeprecated){
			// call clean-up routines if necessary
			// This is irrelevant for this simple rename, but can be helpful for list-feature-changes.
		}
		
		if (!newValue_isDeprecated){
			setNewJavaAttributeName(umlProperty, newValue)
			// ... (additional routines)
		} else {
			// "newValue" is deprecated; avoid propagation
		}
		
		// Technically, if oldValue_isDepracated is true then newValue_isDeprecated is also true, 
		// because the engine does not generate replace-change events where oldValue and newValue are the same.
		// However, in some cases, the newValue may be semantically equivalent to the oldValue.
		// In those cases, it is necessary to check for semantic equivalence instead of identity
		// to determine if the new information needs to be propagated and/or the old information needs to be cleaned-up.
		// 
		// This pattern can be similarly applied to remove- and insert-changes for list-features:
		// remove-change:	val oldValue_isDeprecated = affectedEObject.feature.contains(oldValue) 
		// insert-change:	val newValue_isDeprecated = !affectedEObject.feature.contains(newValue)
	}	
}

routine setNewJavaAttributeName(uml::Property umlAttribute, String newName){
	match {
		val javaAttribute = retrieve java::Field corresponding to umlAttribute
	}
	action {
		update javaAttribute {
			javaAttribute.name = newName
		}
	}
}

// Alternatively, instead of using event validity checks, we can use state based transformation routines.
// pro:	- no event validity check necessary
// con:	- potential for unnecessary repeat propagations of the same information 
// 			if the name has been changed multiple times, before the first change event is processed
// 		- no information about previous/transitional model states, 
//			which can complicate clean-up routines, where they are necessary (irrelevant for this simple example)
routine renameCorrespondingJavaAttribute_stateBased(uml::Property umlAttribute){
	match {
		val javaAttribute = retrieve java::Field corresponding to umlAttribute
	}
	action {
		update javaAttribute {
			javaAttribute.name = umlAttribute.name
		}
	}
}

One alternative to validity checks, the transformations can also be implemented in a state-based manner, where the affectedEObject.feature of the change event only point to what has changed and the transformation calculates the horizontal delta to the target model and how it has to be changed. This has the drawback, that determining the delta may be somewhat complicated (e.g. Which element is no longer in the source elements list, but its correspondence is still present in the target models list and therefore has to be removed?). And it may lead to repeated propagation of the same model state, if a change conflict actually occured, because then the same information is propagated for the deprecated and the new, valid change event.

Another alternative is a more extensive change consolidation, before the events are resolved. Two changes that negate each other could be reduced to no change at all, and two changes where one overwrites the other can be reduced to just one change. This would be the most efficient approach, because it avoids (unnecessary) transformation execution all together and reduces the implementation effort. However, it also removes the ability to react to transitional states, which also removes the ability to perform transformation routines in response to those transitional states (cleanup routines or propagation of information only relevant to the target model). As of now, it is unclear if or when this would be a problem, but it is imaginable that one could want to react to transitional states. Either way, since the Vitruvius framework does not perform this kind of change consolidation as of yet, we recommend using event validity checks.

Demo Implementation

A demo implementation of the concepts that we explained here can be found in the Vitruv-Applications-ComponentBasedSystems repository. The following projects implement transformations using the Existence Check and Validity Check patterns:

  • tools.vitruv.applications.pcmumlclass (PCM Repository <-> UML class diagram)
  • tools.vitruv.applications.umljava (UML class diagram <-> Java)

The composition of both transformations is tested in the following project:

  • tools.vitruv.applications.pcmumlclassjava.tests (PCM Repository <-> UML class diagram <-> Java)
You can’t perform that action at this time.
You signed in with another tab or window. Reload to refresh your session. You signed out in another tab or window. Reload to refresh your session.
Press h to open a hovercard with more details.